From 79cd1234e2ebbef286142ed5ba8344b7cb549cbd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 08:47:43 -0400 Subject: [PATCH 1/5] feat(gallery): visual 'more like this' search (Phase 3 backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GalleryService.similar() ranks images by pgvector cosine distance to a source image's precomputed SigLIP embedding — no query-time ML inference. Composes with the Phase-1/2 scope filters (AND) but replaces the date sort (always nearest-first, bounded top-N, no cursor). Returns None for a missing source (→404), [] for a source with no embedding (video / pending ML); excludes self and NULL-embedding rows. New GET /api/gallery/similar?similar_to=&limit=N. Image-detail payload gains has_embedding so the UI can hide the surface. Alembic 0036 adds an HNSW vector_cosine_ops index on siglip_embedding (1152<2000 dims) so the search is sub-50ms ANN instead of a full scan; one-time ~30-60s build over existing embeddings on deploy. Shared _gallery_images/_image_json helpers de-dup the scroll/similar builders. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0036_siglip_embedding_hnsw_index.py | 41 ++++++ backend/app/api/gallery.py | 60 ++++++-- backend/app/services/gallery_service.py | 84 ++++++++--- tests/test_gallery_similar.py | 134 ++++++++++++++++++ 4 files changed, 289 insertions(+), 30 deletions(-) create mode 100644 alembic/versions/0036_siglip_embedding_hnsw_index.py create mode 100644 tests/test_gallery_similar.py diff --git a/alembic/versions/0036_siglip_embedding_hnsw_index.py b/alembic/versions/0036_siglip_embedding_hnsw_index.py new file mode 100644 index 0000000..a8c1251 --- /dev/null +++ b/alembic/versions/0036_siglip_embedding_hnsw_index.py @@ -0,0 +1,41 @@ +"""image_record.siglip_embedding: HNSW cosine index for "more like this" + +Revision ID: 0036 +Revises: 0035 +Create Date: 2026-06-04 + +Gallery Phase 3 (visual similarity search) ranks images by +`siglip_embedding.cosine_distance(source_embedding)`. Without an index that's +a sequential scan computing a 1152-dim distance for every row — fine at small +scale, but it grows linearly with the library. Add an HNSW index with +`vector_cosine_ops` so the top-N nearest search is sub-50ms ANN. + +1152 dims is under pgvector's 2000-dim HNSW limit, so HNSW (no training, +better recall than IVFFlat) is the right choice. ONE-TIME COST: building the +index over the existing embeddings (~57k vectors on the operator's library) +locks image_record for ~30-60s during this migration on deploy — acceptable +for a single-operator homelab. NULL embeddings (videos / not-yet-embedded +rows) are simply not indexed. +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0036" +down_revision: Union[str, None] = "0035" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Raw SQL: alembic's create_index doesn't express the `USING hnsw (... + # vector_cosine_ops)` access-method + opclass cleanly. Must match the + # query's cosine_distance operator class to be usable by the planner. + op.execute( + "CREATE INDEX ix_image_record_siglip_hnsw " + "ON image_record USING hnsw (siglip_embedding vector_cosine_ops)" + ) + + +def downgrade() -> None: + op.drop_index("ix_image_record_siglip_hnsw", table_name="image_record") diff --git a/backend/app/api/gallery.py b/backend/app/api/gallery.py index 807dd77..4e35bab 100644 --- a/backend/app/api/gallery.py +++ b/backend/app/api/gallery.py @@ -10,6 +10,21 @@ from ..services.gallery_service import GalleryService gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery") +def _image_json(i): + """Serialize a GalleryImage for the scroll/similar list responses.""" + return { + "id": i.id, + "sha256": i.sha256, + "mime": i.mime, + "width": i.width, + "height": i.height, + "created_at": i.created_at.isoformat(), + "posted_at": i.posted_at.isoformat() if i.posted_at else None, + "thumbnail_url": i.thumbnail_url, + "artist": i.artist, + } + + def _parse_date(raw): """Parse a YYYY-MM-DD query value to a UTC midnight datetime, or None. Raises ValueError (→ 400) on a malformed value.""" @@ -76,20 +91,7 @@ async def scroll(): return jsonify( { - "images": [ - { - "id": i.id, - "sha256": i.sha256, - "mime": i.mime, - "width": i.width, - "height": i.height, - "created_at": i.created_at.isoformat(), - "posted_at": i.posted_at.isoformat() if i.posted_at else None, - "thumbnail_url": i.thumbnail_url, - "artist": i.artist, - } - for i in page.images - ], + "images": [_image_json(i) for i in page.images], "next_cursor": page.next_cursor, "date_groups": [ {"year": y, "month": m, "image_ids": ids} for y, m, ids in page.date_groups @@ -98,6 +100,36 @@ async def scroll(): ) +@gallery_bp.route("/similar", methods=["GET"]) +async def similar(): + """Visual "more like this": images ranked by cosine distance to the + `similar_to` image's embedding. Composes with the scope filters (AND) but + ignores post_id and sort. Bounded top-N, no cursor.""" + try: + similar_to = int(request.args["similar_to"]) + limit = int(request.args.get("limit", "100")) + filters, _sort = _parse_filters() + except (KeyError, ValueError): + return jsonify({"error": "similar_to query param required"}), 400 + # post_id is the exclusive post-detail view — not a similarity scope. + scope = {k: v for k, v in filters.items() if k != "post_id"} + async with get_session() as session: + svc = GalleryService(session) + try: + images = await svc.similar(image_id=similar_to, limit=limit, **scope) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if images is None: + return jsonify({"error": "not found"}), 404 + return jsonify( + { + "images": [_image_json(i) for i in images], + "next_cursor": None, + "date_groups": [], + } + ) + + @gallery_bp.route("/timeline", methods=["GET"]) async def timeline(): try: diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index b5060f3..9c31476 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -245,6 +245,27 @@ def _provenance_clause(post_id, artist_id): return None +def _gallery_images(rows, artists: dict[int, dict]) -> list[GalleryImage]: + """Build GalleryImage list from (record, posted_at, eff_date) rows + the + artist hydration map. Shared by scroll() and similar().""" + return [ + GalleryImage( + id=record.id, + path=record.path, + sha256=record.sha256, + mime=record.mime, + width=record.width, + height=record.height, + created_at=record.created_at, + effective_date=eff_date, + posted_at=posted_at, + thumbnail_url=thumbnail_url(record.thumbnail_path, record.sha256, record.mime), + artist=artists.get(record.id), + ) + for record, posted_at, eff_date in rows + ] + + async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]: """Map image_id -> {"name","slug"} via the canonical image_record.artist_id (FC-2d-vii-c). Bounded by page size.""" @@ -324,22 +345,7 @@ class GalleryService: artists = await _artists_for( self.session, [r[0].id for r in rows] ) - images = [ - GalleryImage( - id=record.id, - path=record.path, - sha256=record.sha256, - mime=record.mime, - width=record.width, - height=record.height, - created_at=record.created_at, - effective_date=eff_date, - posted_at=posted_at, - thumbnail_url=thumbnail_url(record.thumbnail_path, record.sha256, record.mime), - artist=artists.get(record.id), - ) - for record, posted_at, eff_date in rows - ] + images = _gallery_images(rows, artists) return GalleryPage( images=images, next_cursor=next_cursor, @@ -505,6 +511,49 @@ class GalleryService: date_min=dmin, date_max=dmax, ) + async def similar( + self, image_id: int, limit: int = 100, *, + tag_ids: list[int] | None = None, artist_id: int | None = None, + media_type: str | None = None, platform: str | None = None, + untagged: bool = False, no_artist: bool = False, + date_from: datetime | None = None, date_to: datetime | None = None, + ) -> list[GalleryImage] | None: + """Visual "more like this": images ranked by cosine distance to + `image_id`'s SigLIP embedding (pgvector, HNSW-indexed — alembic 0036). + No ML inference here; the embedding was computed at import. + + Returns None if the source image doesn't exist (→ 404), [] if it has + no embedding (a video / not-yet-embedded). Composes with the Phase-1/2 + scope filters (AND) but REPLACES the date sort — always nearest-first, + bounded to `limit` (no cursor; distance-ranking has no date cursor). + """ + if limit < 1 or limit > 200: + raise ValueError("limit must be between 1 and 200") + src = await self.session.get(ImageRecord, image_id) + if src is None: + return None + if src.siglip_embedding is None: + return [] + + distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding) + eff = _effective_date_col() + stmt = select(ImageRecord, Post.post_date, eff.label("eff")) + stmt = _outer_join_primary_post(stmt) + stmt = stmt.where( + ImageRecord.siglip_embedding.is_not(None), + ImageRecord.id != image_id, + ) + stmt = _apply_scope( + stmt, tag_ids=tag_ids, post_id=None, + artist_id=artist_id, media_type=media_type, + platform=platform, untagged=untagged, no_artist=no_artist, + date_from=date_from, date_to=date_to, + ) + stmt = stmt.order_by(distance.asc()).limit(limit) + rows = (await self.session.execute(stmt)).all() + artists = await _artists_for(self.session, [r[0].id for r in rows]) + return _gallery_images(rows, artists) + async def get_image_with_tags(self, image_id: int) -> dict | None: record = await self.session.get(ImageRecord, image_id) if record is None: @@ -542,6 +591,9 @@ class GalleryService: "height": record.height, "size_bytes": record.size_bytes, "integrity_status": record.integrity_status, + # Phase 3: lets the modal hide the "Related"/find-similar surface + # for images that have no embedding yet (videos / pending ML). + "has_embedding": record.siglip_embedding is not None, "created_at": record.created_at.isoformat(), "posted_at": posted_at.isoformat() if posted_at else None, "thumbnail_url": thumbnail_url(record.thumbnail_path, record.sha256, record.mime), diff --git a/tests/test_gallery_similar.py b/tests/test_gallery_similar.py new file mode 100644 index 0000000..07ae149 --- /dev/null +++ b/tests/test_gallery_similar.py @@ -0,0 +1,134 @@ +"""Phase-3 visual "more like this" — pgvector cosine ranking over the +precomputed SigLIP image embeddings. No query-time ML inference.""" + +from datetime import UTC, datetime, timedelta + +import pytest + +from backend.app.models import ImageRecord, Tag, TagKind +from backend.app.models.tag import image_tag +from backend.app.services.gallery_service import GalleryService + +pytestmark = pytest.mark.integration + + +def _vec(*head): + """A 1152-dim embedding with the given leading values, rest zero. Cosine + distance to _vec(1,0) grows as the 2nd component grows, so callers can + order fixtures deterministically by direction.""" + v = [0.0] * 1152 + for i, x in enumerate(head): + v[i] = float(x) + return v + + +async def _img(db, n, emb): + rec = ImageRecord( + path=f"/images/sim/{n}.jpg", sha256=f"e{n:063d}", + size_bytes=1, mime="image/jpeg", width=1, height=1, + origin="imported_filesystem", integrity_status="unknown", + siglip_embedding=emb, + ) + base = datetime(2026, 1, 1, 12, 0, tzinfo=UTC) + rec.created_at = base - timedelta(minutes=n) + rec.effective_date = rec.created_at + db.add(rec) + await db.flush() + return rec + + +@pytest.mark.asyncio +async def test_similar_ranks_nearest_first(db): + src = await _img(db, 1, _vec(1, 0)) + near = await _img(db, 2, _vec(1, 0.05)) # almost same direction + mid = await _img(db, 3, _vec(1, 1)) # 45° + far = await _img(db, 4, _vec(0, 1)) # orthogonal + svc = GalleryService(db) + res = await svc.similar(src.id, limit=10) + assert [i.id for i in res] == [near.id, mid.id, far.id] # self excluded + + +@pytest.mark.asyncio +async def test_similar_excludes_null_embeddings(db): + src = await _img(db, 1, _vec(1, 0)) + have = await _img(db, 2, _vec(1, 0.1)) + await _img(db, 3, None) # un-embedded (e.g. a video) → excluded + svc = GalleryService(db) + res = await svc.similar(src.id, limit=10) + assert [i.id for i in res] == [have.id] + + +@pytest.mark.asyncio +async def test_similar_source_without_embedding_returns_empty(db): + src = await _img(db, 1, None) + await _img(db, 2, _vec(1, 0)) + svc = GalleryService(db) + assert await svc.similar(src.id, limit=10) == [] + + +@pytest.mark.asyncio +async def test_similar_missing_source_returns_none(db): + svc = GalleryService(db) + assert await svc.similar(99999, limit=10) is None + + +@pytest.mark.asyncio +async def test_similar_composes_with_tag_filter(db): + src = await _img(db, 1, _vec(1, 0)) + await _img(db, 2, _vec(1, 0.02)) # nearest, but untagged + tagged = await _img(db, 3, _vec(1, 0.6)) # farther, but carries the tag + tag = Tag(name="t", kind=TagKind.general) + db.add(tag) + await db.flush() + await db.execute(image_tag.insert().values( + image_record_id=tagged.id, tag_id=tag.id, source="manual")) + svc = GalleryService(db) + res = await svc.similar(src.id, limit=10, tag_ids=[tag.id]) + assert [i.id for i in res] == [tagged.id] # scope AND-narrows the ranked set + + +@pytest.mark.asyncio +async def test_similar_respects_limit(db): + src = await _img(db, 1, _vec(1, 0)) + for n in range(2, 7): + await _img(db, n, _vec(1, 0.1 * n)) + svc = GalleryService(db) + res = await svc.similar(src.id, limit=2) + assert len(res) == 2 + + +# --- API --- + +@pytest.mark.asyncio +async def test_api_similar_endpoint(client, db): + src = await _img(db, 1, _vec(1, 0)) + near = await _img(db, 2, _vec(1, 0.05)) + await db.commit() + resp = await client.get(f"/api/gallery/similar?similar_to={src.id}&limit=10") + assert resp.status_code == 200 + body = await resp.get_json() + assert [i["id"] for i in body["images"]] == [near.id] + assert body["next_cursor"] is None + + +@pytest.mark.asyncio +async def test_api_similar_404_when_source_missing(client): + resp = await client.get("/api/gallery/similar?similar_to=99999") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_api_similar_requires_param(client): + resp = await client.get("/api/gallery/similar") + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_image_detail_reports_has_embedding(client, db): + embedded = await _img(db, 1, _vec(1, 0)) + plain = await _img(db, 2, None) + await db.commit() + e = await (await client.get(f"/api/gallery/image/{embedded.id}")).get_json() + p = await (await client.get(f"/api/gallery/image/{plain.id}")).get_json() + assert e["has_embedding"] is True + assert p["has_embedding"] is False -- 2.52.0 From 21a73cd1dc276e0fde955fffa085b4a1ef012079 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 08:52:42 -0400 Subject: [PATCH 2/5] feat(gallery): visual 'more like this' UI (Phase 3 frontend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modal 'Related' strip (RelatedStrip.vue) — top-12 similar thumbs, fetched on its own DEFERRED, single-flighted path (200ms after the modal is up) so it never blocks or slows the modal; collapses silently on empty/slow/error and is hidden when the image has no embedding (has_embedding flag). 'See all similar' closes the modal and navigates the gallery to ?similar_to=. Gallery store: similar_to filter field + loadSimilar() (ranked, hasMore=false, no timeline); applyFilterFromQuery routes similar-mode to /similar with the scope filters composed; cloneFilter/filterToQuery carry similar_to. Filter bar: clearable 'Similar to #id' chip, sort hidden in similar-mode; timeline sidebar hidden too. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/gallery/GalleryFilterBar.vue | 10 ++ frontend/src/components/modal/ImageViewer.vue | 4 + .../src/components/modal/RelatedStrip.vue | 133 ++++++++++++++++++ frontend/src/stores/gallery.js | 50 ++++++- frontend/src/views/GalleryView.vue | 5 +- .../components/galleryRelatedStrip.spec.js | 73 ++++++++++ frontend/test/gallery.spec.js | 27 ++++ 7 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/modal/RelatedStrip.vue create mode 100644 frontend/test/components/galleryRelatedStrip.spec.js diff --git a/frontend/src/components/gallery/GalleryFilterBar.vue b/frontend/src/components/gallery/GalleryFilterBar.vue index a19063f..9e53a75 100644 --- a/frontend/src/components/gallery/GalleryFilterBar.vue +++ b/frontend/src/components/gallery/GalleryFilterBar.vue @@ -38,6 +38,12 @@ prepend-icon="mdi-account" @click:close="clearArtist" >{{ store.artistLabel || `Artist #${store.filter.artist_id}` }} + Similar to #{{ store.filter.similar_to }} @@ -52,7 +58,9 @@ Videos + store.filter.artist_id != null || store.filter.media_type != null || store.filter.sort !== 'newest' || + store.filter.similar_to != null || hasRefineFilters.value ) @@ -190,6 +199,7 @@ function clearArtist() { store.noteArtistLabel(null) pushFilter((n) => { n.artist_id = null }) } +function clearSimilar() { pushFilter((n) => { n.similar_to = null }) } function setMedia(m) { pushFilter((n) => { n.media_type = m }) } function setSort(s) { pushFilter((n) => { n.sort = s }) } function clearAll() { router.push({ name: 'gallery', query: {} }) } diff --git a/frontend/src/components/modal/ImageViewer.vue b/frontend/src/components/modal/ImageViewer.vue index 4cb8fe5..1529bbc 100644 --- a/frontend/src/components/modal/ImageViewer.vue +++ b/frontend/src/components/modal/ImageViewer.vue @@ -51,6 +51,9 @@ @@ -65,6 +68,7 @@ import ImageCanvas from './ImageCanvas.vue' import VideoCanvas from './VideoCanvas.vue' import TagPanel from './TagPanel.vue' import ProvenancePanel from './ProvenancePanel.vue' +import RelatedStrip from './RelatedStrip.vue' const emit = defineEmits(['close']) diff --git a/frontend/src/components/modal/RelatedStrip.vue b/frontend/src/components/modal/RelatedStrip.vue new file mode 100644 index 0000000..7264df4 --- /dev/null +++ b/frontend/src/components/modal/RelatedStrip.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/frontend/src/stores/gallery.js b/frontend/src/stores/gallery.js index 39169f7..bad0d8b 100644 --- a/frontend/src/stores/gallery.js +++ b/frontend/src/stores/gallery.js @@ -27,6 +27,9 @@ export const useGalleryStore = defineStore('gallery', () => { // Phase-2 faceted refine params. platform: null, untagged: false, no_artist: false, date_from: null, date_to: null, + // Phase-3 visual similarity: when set, the gallery is in "similar mode" — + // ranked by cosine distance to this image, bounded top-N, no cursor. + similar_to: null, }) // Live facet counts for the refine panel; fetched on-demand (panel open + @@ -84,6 +87,39 @@ export const useGalleryStore = defineStore('gallery', () => { } } + // Visual "more like this": ranked top-N by cosine distance, scope filters + // composed (AND). No cursor / no timeline — bounded result set. + async function loadSimilar() { + inflight.cancel() + images.value = [] + dateGroups.value = [] + nextCursor.value = null + timelineBuckets.value = [] + loading.value = true + error.value = null + const t = inflight.claim() + try { + const f = filter.value + const params = { similar_to: f.similar_to, limit: 100 } + if (f.tag_ids.length) params.tag_id = f.tag_ids.join(',') + if (f.artist_id) params.artist_id = f.artist_id + if (f.media_type) params.media = f.media_type + if (f.platform) params.platform = f.platform + if (f.untagged) params.untagged = '1' + if (f.no_artist) params.no_artist = '1' + if (f.date_from) params.date_from = f.date_from + if (f.date_to) params.date_to = f.date_to + const body = await api.get('/api/gallery/similar', { params }) + if (!t.isCurrent()) return + images.value = body.images + // ranked + bounded → no next page (nextCursor stays null → hasMore false) + } catch (e) { + error.value = e.message + } finally { + if (t.isCurrent()) loading.value = false + } + } + async function jumpTo(year, month) { // Rapid timeline-jump clicks need the same race guard as // loadMore — first jump's late body could clobber the second @@ -150,9 +186,15 @@ export const useGalleryStore = defineStore('gallery', () => { no_artist: _truthy(q.no_artist), date_from: _parseDate(q.date_from), date_to: _parseDate(q.date_to), + similar_to: _toId(q.similar_to), + } + if (filter.value.similar_to) { + // Similar mode: ranked, no timeline scroll. + await loadSimilar() + } else { + await loadInitial() + await loadTimeline() } - await loadInitial() - await loadTimeline() _resolveLabels() } @@ -200,7 +242,7 @@ export const useGalleryStore = defineStore('gallery', () => { images, dateGroups, hasMore, isEmpty, loading, error, filter, tagLabels, artistLabel, timelineBuckets, timelineLoading, facets, facetsLoading, - loadInitial, loadMore, loadTimeline, jumpTo, loadFacets, + loadInitial, loadMore, loadTimeline, jumpTo, loadFacets, loadSimilar, applyFilterFromQuery, noteTagLabel, noteArtistLabel, } }) @@ -213,6 +255,7 @@ export function cloneFilter(f) { tag_ids: [...f.tag_ids], artist_id: f.artist_id, media_type: f.media_type, sort: f.sort, platform: f.platform, untagged: f.untagged, no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to, + similar_to: f.similar_to, } } @@ -227,6 +270,7 @@ export function filterToQuery(f) { if (f.no_artist) q.no_artist = '1' if (f.date_from) q.date_from = f.date_from if (f.date_to) q.date_to = f.date_to + if (f.similar_to) q.similar_to = String(f.similar_to) return q } diff --git a/frontend/src/views/GalleryView.vue b/frontend/src/views/GalleryView.vue index a264a11..62ddd12 100644 --- a/frontend/src/views/GalleryView.vue +++ b/frontend/src/views/GalleryView.vue @@ -16,7 +16,10 @@ - + diff --git a/frontend/test/components/galleryRelatedStrip.spec.js b/frontend/test/components/galleryRelatedStrip.spec.js new file mode 100644 index 0000000..28ce944 --- /dev/null +++ b/frontend/test/components/galleryRelatedStrip.spec.js @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { flushPromises } from '@vue/test-utils' + +import RelatedStrip from '../../src/components/modal/RelatedStrip.vue' +import { useModalStore } from '../../src/stores/modal.js' +import { freshPinia, mountComponent } from '../support/mountComponent.js' + +function stubFetch(handler) { + globalThis.fetch = vi.fn(async (url) => { + const { status, body } = handler(url) + return { + ok: status >= 200 && status < 300, + status, statusText: String(status), + text: async () => JSON.stringify(body), + } + }) +} + +describe('RelatedStrip (modal "more like this")', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() }) + + it('stays hidden and never fetches when the image has no embedding', async () => { + const pinia = freshPinia() + useModalStore().current = { id: 1, has_embedding: false } + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy + const w = mountComponent(RelatedStrip, { pinia }) + await vi.advanceTimersByTimeAsync(500) + expect(fetchSpy).not.toHaveBeenCalled() + expect(w.find('.fc-related').exists()).toBe(false) + }) + + it('deferred-fetches and renders similar thumbs for an embedded image', async () => { + const pinia = freshPinia() + useModalStore().current = { id: 1, has_embedding: true } + stubFetch(() => ({ + status: 200, + body: { images: [{ id: 2, thumbnail_url: '/t2' }, { id: 3, thumbnail_url: '/t3' }] }, + })) + const w = mountComponent(RelatedStrip, { pinia }) + // Nothing before the defer elapses (modal image gets priority). + expect(globalThis.fetch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(250) + await flushPromises() + expect(globalThis.fetch).toHaveBeenCalledTimes(1) + expect(w.findAll('.fc-related__item').length).toBe(2) + }) + + it('collapses silently when the similar fetch returns nothing', async () => { + const pinia = freshPinia() + useModalStore().current = { id: 1, has_embedding: true } + stubFetch(() => ({ status: 200, body: { images: [] } })) + const w = mountComponent(RelatedStrip, { pinia }) + await vi.advanceTimersByTimeAsync(250) + await flushPromises() + expect(w.find('.fc-related').exists()).toBe(false) + }) + + it('clicking a thumb opens that image in the modal', async () => { + const pinia = freshPinia() + const modal = useModalStore() + modal.current = { id: 1, has_embedding: true } + const openSpy = vi.spyOn(modal, 'open').mockResolvedValue() + stubFetch(() => ({ status: 200, body: { images: [{ id: 7, thumbnail_url: '/t7' }] } })) + const w = mountComponent(RelatedStrip, { pinia }) + await vi.advanceTimersByTimeAsync(250) + await flushPromises() + await w.find('.fc-related__item').trigger('click') + expect(openSpy).toHaveBeenCalledWith(7) + }) +}) diff --git a/frontend/test/gallery.spec.js b/frontend/test/gallery.spec.js index c5d52f8..93ce5c6 100644 --- a/frontend/test/gallery.spec.js +++ b/frontend/test/gallery.spec.js @@ -122,6 +122,28 @@ describe('gallery store: composable filter', () => { expect(s.facets.total).toBe(4) }) + it('similar_to routes applyFilterFromQuery to the /similar endpoint, not scroll', async () => { + const s = useGalleryStore() + const urls = [] + stubFetch((url) => { + urls.push(url) + if (url.includes('/api/gallery/similar')) { + return { status: 200, body: { images: [{ id: 9 }], next_cursor: null, date_groups: [] } } + } + return { status: 200, body: EMPTY } + }) + await s.applyFilterFromQuery({ similar_to: '5', tag_id: '3' }) + expect(s.filter.similar_to).toBe(5) + const sim = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/similar'))) + expect(sim).toContain('similar_to=5') + expect(sim).toContain('limit=100') + expect(sim).toContain('tag_id=3') // scope composes + expect(s.images.map((i) => i.id)).toEqual([9]) + expect(s.hasMore).toBe(false) // ranked + bounded → no pages + expect(urls.some((u) => u.includes('/api/gallery/scroll'))).toBe(false) + expect(urls.some((u) => u.includes('/api/gallery/timeline'))).toBe(false) + }) + it('loadInitial issues exactly one scroll request at the initial limit', async () => { const s = useGalleryStore() const urls = [] @@ -154,6 +176,11 @@ describe('filterToQuery / cloneFilter', () => { expect(q.sort).toBeUndefined() // newest is the default → omitted }) + it('serializes similar_to', () => { + expect(filterToQuery({ tag_ids: [], similar_to: 42 }).similar_to).toBe('42') + expect(filterToQuery({ tag_ids: [], similar_to: null }).similar_to).toBeUndefined() + }) + it('cloneFilter copies refine fields and detaches tag_ids', () => { const orig = { tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest', -- 2.52.0 From 04973947100acc9a78b83fefbc35bdd0fb87255c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 09:02:21 -0400 Subject: [PATCH 3/5] feat(ui): double showcase cadence + filter bar matches TopNav frost - Showcase reveal cadence 80ms -> 160ms (slower, more deliberate one-at-a-time cascade per operator). Bump showcase.spec timer advances to cover 60x160ms. - Gallery filter bar now uses the EXACT gradiated-obsidian frost + blur as TopNav (was a flat rgba(...,0.55) block), so the two read as one continuous piece of chrome with images visibly scrolling under both; the nav's transparent bottom edge against the bar's opaque top leaves a faint seam that separates them at the very top of scroll. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/gallery/GalleryFilterBar.vue | 17 +++++++++++++---- frontend/src/stores/showcase.js | 3 ++- frontend/test/showcase.spec.js | 8 ++++---- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/gallery/GalleryFilterBar.vue b/frontend/src/components/gallery/GalleryFilterBar.vue index 9e53a75..f166b89 100644 --- a/frontend/src/components/gallery/GalleryFilterBar.vue +++ b/frontend/src/components/gallery/GalleryFilterBar.vue @@ -217,7 +217,7 @@ function pushFilter(mutate) { diff --git a/frontend/src/components/gallery/BulkEditorPanel.vue b/frontend/src/components/gallery/BulkEditorPanel.vue index 2f8e11e..e24b335 100644 --- a/frontend/src/components/gallery/BulkEditorPanel.vue +++ b/frontend/src/components/gallery/BulkEditorPanel.vue @@ -186,7 +186,9 @@ async function onDeleteConfirm(token) { diff --git a/frontend/src/components/gallery/GalleryFilterBar.vue b/frontend/src/components/gallery/GalleryFilterBar.vue index f166b89..39c86ac 100644 --- a/frontend/src/components/gallery/GalleryFilterBar.vue +++ b/frontend/src/components/gallery/GalleryFilterBar.vue @@ -258,4 +258,12 @@ function pushFilter(mutate) { .fc-filterbar__search { max-width: 320px; min-width: 200px; } .fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .fc-filterbar__sort { max-width: 150px; } + +/* Phones: the search's 200px min-width jams the wrapping bar. Give search its + own full-width row and let sort grow; everything else wraps under it. */ +@media (max-width: 600px) { + .fc-filterbar { gap: 8px; } + .fc-filterbar__search { min-width: 100%; max-width: none; } + .fc-filterbar__sort { max-width: none; flex: 1 1 auto; } +} diff --git a/frontend/src/views/ArtistsView.vue b/frontend/src/views/ArtistsView.vue index b79ca83..3e3c613 100644 --- a/frontend/src/views/ArtistsView.vue +++ b/frontend/src/views/ArtistsView.vue @@ -76,7 +76,9 @@ onMounted(async () => { } .fc-artists__grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); + /* min(440px, 100%) so a card never exceeds the viewport — on phones the + min-track collapses to 100% (single column) instead of overflowing. */ + grid-template-columns: repeat(auto-fill, minmax(min(440px, 100%), 1fr)); gap: 12px; } .fc-artists__sentinel { diff --git a/frontend/src/views/GalleryView.vue b/frontend/src/views/GalleryView.vue index 62ddd12..fa64033 100644 --- a/frontend/src/views/GalleryView.vue +++ b/frontend/src/views/GalleryView.vue @@ -71,4 +71,9 @@ function openImage(id) { .fc-gallery-layout { flex-direction: column-reverse; } .fc-gallery-layout__sidebar { width: 100%; max-height: 200px; } } +/* Phones: the year/month timeline strip eats vertical space and reads poorly + as a horizontal band — drop it; the gallery scroll is the primary nav here. */ +@media (max-width: 600px) { + .fc-gallery-layout__sidebar { display: none; } +} -- 2.52.0 From 4fd6d4cc29dc9a112ccd59836de7ecb300c57cc7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 09:26:56 -0400 Subject: [PATCH 5/5] =?UTF-8?q?fix(ui):=20mobile=20pass=202=20=E2=80=94=20?= =?UTF-8?q?Posts=20&=20Subscriptions=20filter=20bars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostsFilterBar didn't wrap and its artist/platform fields had inline min-widths (240/180px) a media query can't override → horizontal overflow on phones. Moved widths to classes, added flex-wrap, and <600px each field takes a full-width row. SubscriptionsTab's status/search inline max-widths likewise moved to classes; <600px they go full-width and the v-spacer is dropped so the search isn't shoved around. Verified as already-fine (sweep false positives, no change): PostCard (default body is a stacked column; only goes row at container >=800px), SeriesReaderView (already has a <=768px block: nav drawer 150px, quick-nav stacks). The subscriptions v-data-table scrolls horizontally within its own wrapper, so it doesn't widen the page — a true mobile card layout is a larger follow-up if wanted. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/components/posts/PostsFilterBar.vue | 12 ++++++++++-- .../components/subscriptions/SubscriptionsTab.vue | 12 ++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/posts/PostsFilterBar.vue b/frontend/src/components/posts/PostsFilterBar.vue index 34e0c01..d7639b1 100644 --- a/frontend/src/components/posts/PostsFilterBar.vue +++ b/frontend/src/components/posts/PostsFilterBar.vue @@ -13,7 +13,7 @@ clearable no-filter return-object - style="min-width: 240px" + class="fc-posts-filters__artist" @update:search="onArtistSearch" @update:model-value="onArtistPicked" /> @@ -25,7 +25,7 @@ density="compact" hide-details clearable - style="min-width: 180px" + class="fc-posts-filters__platform" @update:model-value="emitFilters" /> @@ -135,6 +135,14 @@ watch(() => props.platform, (val) => { display: flex; gap: 0.75rem; align-items: center; + flex-wrap: wrap; padding-bottom: 1rem; } +.fc-posts-filters__artist { min-width: 240px; flex: 1 1 240px; max-width: 360px; } +.fc-posts-filters__platform { min-width: 180px; flex: 1 1 180px; max-width: 240px; } +/* Phones: each field takes a full-width row instead of overflowing. */ +@media (max-width: 600px) { + .fc-posts-filters__artist, + .fc-posts-filters__platform { min-width: 100%; max-width: none; flex-basis: 100%; } +} diff --git a/frontend/src/components/subscriptions/SubscriptionsTab.vue b/frontend/src/components/subscriptions/SubscriptionsTab.vue index 73cbb0b..cee737b 100644 --- a/frontend/src/components/subscriptions/SubscriptionsTab.vue +++ b/frontend/src/components/subscriptions/SubscriptionsTab.vue @@ -26,14 +26,14 @@ :items="STATUS_OPTIONS" :disabled="needsAttention" density="compact" variant="outlined" hide-details - style="max-width: 180px" + class="fc-subs__status" /> @@ -568,6 +568,14 @@ async function bulkDelete() { padding: 8px 0 1rem; } .fc-subs__bar .v-chip { cursor: pointer; } +.fc-subs__status { max-width: 180px; } +.fc-subs__search { max-width: 320px; flex: 1 1 200px; } +/* Phones: status + search each take a full-width row; drop the spacer that + would otherwise eat a row pushing them around. */ +@media (max-width: 600px) { + .fc-subs__status, .fc-subs__search { max-width: none; flex-basis: 100%; } + .fc-subs__bar :deep(.v-spacer) { display: none; } +} .fc-subs__loading, .fc-subs__empty { display: flex; justify-content: center; padding: 2rem; color: rgb(var(--v-theme-on-surface-variant)); -- 2.52.0