Compare commits

...
21 Commits
Author SHA1 Message Date
bvandeusen 2a8f7cd8b6 Merge pull request '#69 dev→main: release v26.06.04.0' from dev into main
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 3m2s
Build images / sign-extension (push) Has been skipped
Build images / build-ml (push) Successful in 6s
Build images / build-web (push) Successful in 6s
2026-06-04 23:16:12 -04:00
bvandeusenandClaude Opus 4.8 86efbf7f2c fix(modal): kebab menus open via explicit v-model, not activator click
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 17s
CI / integration (push) Successful in 2m56s
Operator-confirmed on a fresh build: both the tag-chip and suggestion
kebabs still never opened. The prior 8326e54 'fix' only wrapped them in a
<span @click.stop> — inert for SuggestionItem (no parent capture) — and
never addressed why the `#activator`/`v-bind="props"` click failed to
toggle the menu inside the teleported ImageViewer modal. The dialogs in
that same modal open via v-model and work, so drive the menus the same way:

- The activator (v-btn / v-icon) toggles a reactive flag with @click.stop
  (which also shields the chip's close button / any parent).
- The v-menu binds that flag (v-model / :model-value) and uses
  activator="parent" with :open-on-click="false" purely for positioning,
  so opening no longer depends on Vuetify's activator-click path.
- TagPanel tracks a single openTagId (one chip menu open at a time).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:01:55 -04:00
bvandeusenandClaude Opus 4.8 3a0cca5aca fix(tags): allow creating a character with no fandom
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 3m4s
Not all characters belong to a fandom (original characters, unsorted).
The create flow forced every new character through FandomPicker, whose
only outcomes were 'Use this fandom' (disabled until one is picked) or
Cancel (which aborts the whole creation) — there was no way to confirm a
character with no fandom.

- FandomPicker: add a 'No fandom' action that emits confirm(null).
- TagAutocomplete.onFandomChosen: pass fandom_id: null when null is
  emitted.

Backend already supported this end to end (Tag.fandom_id nullable, the
CHECK only forbids fandom_id on non-character kinds, tag_service
find_or_create defaults fandom_id=None, API reads body.get). A fandom can
still be assigned later from the chip kebab's 'Set fandom…'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:50:19 -04:00
bvandeusen 83f8af8090 Merge pull request 'dev→main: surface near-duplicate (pHash) control + reorder import tab' (#68) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 2m56s
2026-06-04 21:39:44 -04:00
bvandeusenandClaude Opus 4.8 a5b3702863 feat(settings): surface the near-duplicate (pHash) control + reorder import tab
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 10s
CI / integration (push) Successful in 3m6s
The phash_threshold knob (controls whether edits/variants of an image are
dropped as near-duplicates on import) was buried at the bottom of the import
filters form and labelled opaquely, so it read as 'missing'. Hoist it to the
TOP of the form as a 'Near-duplicate sensitivity' section: a labelled slider
(Exact / Strict / Default / Loose stops, 0-16) for the gist + the precise
number field, both bound to phash_threshold, with copy that says plainly to
lower it if variants are being dropped.

Also swap the import-tab order to filters → trigger → recent-tasks (filters on
top per operator); the task list stays directly under the trigger for hit/miss
feedback adjacency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:42:32 -04:00
bvandeusen 9a2617c1a2 Merge pull request 'dev→main: post-card redesign (images→modal, in-place text expand)' (#67) from dev into main
CI / lint (push) Failing after 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 24s
CI / integration (push) Successful in 2m57s
2026-06-04 17:32:50 -04:00
bvandeusenandClaude Opus 4.8 509a7958cf feat(posts): images open the modal; only text expands in place
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 27s
CI / integration (push) Successful in 3m2s
Post cards no longer expand the whole card on click (the old two-click path
to the images). The card is compact-only now:
- Hero / rail thumbs / the +N tile are buttons that open the post-scoped image
  modal (modal.open(id, { postImageIds })) so you view big + arrow through ALL
  the post's images. The feed caps thumbnails at 6, so for posts with more we
  lazily getPostFull to get the complete id list; +N opens at the first hidden
  image.
- The description is the ONLY in-place expansion: a Show more / Show less toggle
  shown only when the text is actually truncated (server description_truncated
  flag OR a measured CSS-clamp overflow, ResizeObserver-guarded). Expanding
  loads description_full when server-truncated and renders it unclamped.
- Attachments: download chips now render inline in the compact card (the feed
  already carries download_url), since the expanded view is gone.

Removes PostImageGrid.vue (the mosaic, now unused). Tests cover show-more
visibility + image-click opening the scoped modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:29:19 -04:00
bvandeusen 81688815a0 Merge pull request 'dev→main: similar-search render fix + reset-content-tagging + scan persistence' (#66) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m58s
2026-06-04 16:59:52 -04:00
bvandeusenandClaude Opus 4.8 5a6a95682d fix(cleanup): library scans survive navigation, reconnect on return
CI / frontend-build (push) Successful in 24s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / integration (push) Successful in 2m56s
The transparency / single-color audit cards held the run + poll timer in
local component state, so navigating away destroyed both and onMounted never
reconnected — the Celery scan kept running and writing LibraryAuditRun, but
the UI forgot it. Now each card, on mount, fetches its rule's latest run
(GET /api/cleanup/audit?rule=<rule>&limit=1) and rehydrates: shows progress +
resumes polling if still running, or shows the completed result (ready/applied/
error) so the operator can act on it after returning. Adds the ?rule= filter
to the audit-history endpoint + cleanup store latestAuditForRule().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:55:20 -04:00
bvandeusenandClaude Opus 4.8 91b0145bc8 feat(tags): 'Reset content tagging' admin action
CI / backend-lint-and-test (push) Successful in 11s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 30s
CI / integration (push) Successful in 2m57s
Wipe every general + character tag so the operator can re-tag from scratch via
the Camie auto-suggest, while PRESERVING fandoms, series (+ series_page order),
and each image's stored tagger_predictions (so suggestions repopulate
immediately). One set-based DELETE FROM tag WHERE kind IN ('general','character')
— the five tag-referencing tables all cascade, so applications + aliases +
allowlist + rejections + centroids clear automatically; series tags aren't
deleted so series survive; Tag.fandom_id is SET NULL so fandoms are untouched.

Reuses the established dry-run-preview -> confirm pattern: cleanup_service.
reset_content_tagging() + POST /api/admin/tags/reset-content +
TagMaintenanceCard section with a backup-first warning and a red confirm
showing exact counts (tags by kind + image applications). Irreversible except
via DB backup restore; the wipe only fires when the operator confirms.

Tests: service dry-run counts + live delete preserves fandom/series/series_page
while content tags + their image_tag cascade away; API dry-run wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:47:36 -04:00
bvandeusenandClaude Opus 4.8 26e47a86cb fix(gallery): render similar-mode results (flat list when no date groups)
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m58s
The 'See all similar' takeover fetched /api/gallery/similar fine (200, ~100
results) but the grid showed nothing: GalleryGrid renders ONLY by iterating
store.dateGroups, and similar-mode returns date_groups=[] (results are ranked
by cosine distance, not chronological). Zero groups → zero tiles despite
store.images being full. Add a flat fallback: when there are no date groups
but images exist, render them as one ungrouped list in ranked order (no date
headers). The modal Related strip was unaffected (it renders its images
directly). Test locks both the flat and grouped paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:24:52 -04:00
bvandeusen 773128c3bf Merge pull request 'dev→main: purpose-built mobile layout for subscriptions hub' (#65) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 14s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 38s
CI / integration (push) Successful in 2m56s
2026-06-04 13:43:56 -04:00
bvandeusenandClaude Opus 4.8 928e3037f0 fix(ui): purpose-built mobile layout for subscriptions hub
CI / frontend-build (push) Successful in 59s
CI / integration (push) Successful in 2m56s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 10s
Vuetify's auto card-stack was too verbose (one subscription filled the whole
phone screen) and the expanded sources still needed lateral scroll. Replace it
below 600px (useDisplay) with a custom compact-card list: each subscription is
a 2-line card (name + health + expand chevron, then platform chips + sources
count + last activity) so several fit per screen. Expanding shows the action
row + each source as a STACKED SourceCard (new) — platform/url/enabled/last/
next/errors/actions laid out vertically, no horizontal scroll. The mobile
cards drive the same selected/expanded key arrays as the desktop data-table,
so selection and bulk actions are unchanged. Desktop keeps the v-data-table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:40:25 -04:00
bvandeusen ce7b154ae9 Merge pull request 'dev→main: subscriptions table mobile card layout' (#64) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 14s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m57s
2026-06-04 12:56:58 -04:00
bvandeusenandClaude Opus 4.8 b08b12eb8f fix(ui): subscriptions table → card layout on mobile
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 1m8s
CI / integration (push) Successful in 2m56s
The subscriptions v-data-table (select + expand + 6 cols + a nested 8-col
sources sub-table) horizontally-scrolled on phones. Set mobile-breakpoint=600
so Vuetify stacks each subscription row into a label:value card below 600px;
the custom item slots (platform chips, health dot, action buttons) render as
card rows. The expanded sources detail reclaims its desktop indent on mobile
and keeps its own horizontal scroll for the wide source columns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:53:40 -04:00
bvandeusen 9430a9d9c3 Merge pull request 'dev→main: gallery similarity search (Phase 3) + UI/mobile polish' (#63) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / integration (push) Successful in 2m56s
2026-06-04 11:21:26 -04:00
bvandeusenandClaude Opus 4.8 4fd6d4cc29 fix(ui): mobile pass 2 — Posts & Subscriptions filter bars
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 34s
CI / integration (push) Successful in 2m58s
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) <noreply@anthropic.com>
2026-06-04 09:26:56 -04:00
bvandeusenandClaude Opus 4.8 304e8aa878 fix(ui): mobile responsiveness — nav hamburger + primary-path fixes
CI / backend-lint-and-test (push) Successful in 12s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 29s
CI / integration (push) Successful in 2m57s
The top nav packed brand + health + pipeline chip + ~7 inline links + an
action slot into one flex row, colliding/overflowing on phones (operator:
'almost unusable'). Below 768px the links now fold into a hamburger v-menu;
below 480px the brand text hides (glyph still brands). Plus the primary
browsing path:
- BulkEditorPanel: fixed 320px -> min(320px, 90vw) so it can't swallow the screen.
- GalleryFilterBar: <600px gives search its own full-width row (its 200px
  min-width was jamming the wrapping bar); sort grows.
- GalleryFacetPanel: <480px wraps groups + lets the side-by-side date inputs
  grow full-width.
- ArtistsView grid: minmax(min(440px,100%),1fr) so a card never overflows
  (single column on phones).
- GalleryView: hide the year/month timeline strip <600px.
ImageViewer already stacks its side panel below the image <900px (left as-is).

Secondary surfaces (Posts/Subscriptions filter bars, SubscriptionsTab table,
SeriesReader, PostCard) still need a mobile pass — follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:06:33 -04:00
bvandeusenandClaude Opus 4.8 0497394710 feat(ui): double showcase cadence + filter bar matches TopNav frost
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 17s
CI / integration (push) Successful in 2m56s
- 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) <noreply@anthropic.com>
2026-06-04 09:02:21 -04:00
bvandeusenandClaude Opus 4.8 21a73cd1dc feat(gallery): visual 'more like this' UI (Phase 3 frontend)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m57s
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=<id>.

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) <noreply@anthropic.com>
2026-06-04 08:52:42 -04:00
bvandeusenandClaude Opus 4.8 79cd1234e2 feat(gallery): visual 'more like this' search (Phase 3 backend)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 28s
CI / integration (push) Successful in 2m57s
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=<id>&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) <noreply@anthropic.com>
2026-06-04 08:47:43 -04:00
43 changed files with 1602 additions and 388 deletions
@@ -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")
+21
View File
@@ -224,6 +224,27 @@ async def tags_purge_legacy():
return jsonify(result)
@admin_bp.route("/tags/reset-content", methods=["POST"])
async def tags_reset_content():
"""Tier-A: delete ALL general + character tags (the Camie-suggestable
content vocabulary) so the operator can re-tag from scratch via
auto-suggest. fandom + series tags + series_page ordering are preserved,
and image tagger_predictions are untouched so suggestions repopulate.
dry-run preview returns per-kind counts + applications + a sample so the
UI shows exactly what'll go before the operator confirms (dry_run=false).
Irreversible except via DB backup restore."""
from ..services.cleanup_service import reset_content_tagging
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", False))
async with get_session() as session:
result = await session.run_sync(
lambda sync_sess: reset_content_tagging(sync_sess, dry_run=dry_run)
)
return jsonify(result)
@admin_bp.route("/maintenance/db-stats", methods=["GET"])
async def db_stats():
"""Per-table bloat readout (pg_stat_user_tables) for the high-churn tables
+8 -5
View File
@@ -154,12 +154,15 @@ async def audit_history():
limit = min(int(request.args.get("limit", "20")), 100)
except ValueError:
return _bad("invalid_limit")
# Optional rule filter so a card can reconnect to ITS latest run on mount
# (?rule=transparency&limit=1) — the audit survives navigation; the UI
# rehydrates from this rather than losing the in-flight scan.
rule = request.args.get("rule") or None
async with get_session() as session:
rows = (await session.execute(
select(LibraryAuditRun)
.order_by(LibraryAuditRun.id.desc())
.limit(limit)
)).scalars().all()
stmt = select(LibraryAuditRun).order_by(LibraryAuditRun.id.desc())
if rule is not None:
stmt = stmt.where(LibraryAuditRun.rule == rule)
rows = (await session.execute(stmt.limit(limit))).scalars().all()
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
+46 -14
View File
@@ -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:
+56
View File
@@ -455,6 +455,62 @@ def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict:
return result
# The Camie-suggestable CONTENT vocabulary. "Reset content tagging" wipes
# these so the operator can re-tag from scratch via auto-suggest. fandom +
# series (and series_page ordering) are deliberately NOT here — they're kept.
RESETTABLE_TAG_KINDS = ("general", "character")
def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
"""Count (dry_run) or DELETE every general + character tag so the operator
can re-tag from scratch via the Camie auto-suggest.
PRESERVED: fandom + series tags and their series_page ordering, plus every
image's image_record.tagger_predictions (untouched) so suggestions
repopulate immediately. CASCADE on image_tag / tag_alias / tag_allowlist /
tag_reference_embedding / tag_suggestion_rejection clears each deleted
tag's applications + metadata. Tag.fandom_id is SET NULL, so deleting
character tags never touches the fandom rows. Irreversible except via DB
backup restore.
Returns:
{"by_kind": {"general": N, "character": M},
"count": total tags,
"applications": image_tag rows that will be / were removed,
"sample_names": [first 50],
and on live runs "deleted": total}
"""
predicate = Tag.kind.in_(RESETTABLE_TAG_KINDS)
rows = session.execute(
select(Tag.id, Tag.name, Tag.kind).where(predicate)
).all()
by_kind: dict[str, int] = {}
for _id, _name, kind in rows:
key = kind.value if hasattr(kind, "value") else str(kind)
by_kind[key] = by_kind.get(key, 0) + 1
# Headline impact: applications (image_tag rows) that vanish via cascade.
applications = session.execute(
select(func.count())
.select_from(image_tag)
.where(image_tag.c.tag_id.in_(select(Tag.id).where(predicate)))
).scalar_one()
sample = [name for _id, name, _kind in rows[:50]]
total = len(rows)
result = {
"by_kind": by_kind,
"count": total,
"applications": applications,
"sample_names": sample,
}
if dry_run:
return result
if total:
session.execute(Tag.__table__.delete().where(predicate))
session.commit()
result["deleted"] = total
return result
# ---------------------------------------------------------------------------
# FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules.
# ---------------------------------------------------------------------------
+68 -16
View File
@@ -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),
+46 -4
View File
@@ -11,6 +11,8 @@
<PipelineStatusChip />
</div>
<!-- Desktop: inline links, centered. Hidden on mobile (see media query),
where they fold into the hamburger menu on the right. -->
<nav class="fc-links">
<RouterLink
v-for="r in navRoutes"
@@ -20,9 +22,31 @@
>{{ r.meta.title }}</RouterLink>
</nav>
<!-- Per-view contextual actions teleport here (Showcase: Shuffle,
Gallery: Select). TopNav owns the slot, not its contents. -->
<div id="fc-nav-actions" class="fc-nav-actions" />
<div class="fc-nav-right">
<!-- Per-view contextual actions teleport here (Showcase: Shuffle,
Gallery: Select). TopNav owns the slot, not its contents. -->
<div id="fc-nav-actions" class="fc-nav-actions" />
<!-- Mobile nav: the link row collides with brand + actions below ~768px
(7+ links in one flex row), so collapse it into a menu. -->
<v-menu location="bottom end">
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon="mdi-menu" variant="text" size="small"
class="fc-nav-burger" aria-label="Menu"
/>
</template>
<v-list density="compact" min-width="180">
<v-list-item
v-for="r in navRoutes"
:key="r.name"
:to="{ name: r.name }"
:title="r.meta.title"
/>
</v-list>
</v-menu>
</div>
</header>
</template>
@@ -137,7 +161,7 @@ const health = computed(() => {
align-items: center;
flex-shrink: 0;
}
.fc-nav-actions {
.fc-nav-right {
flex: 1 1 0;
min-width: 0;
display: flex;
@@ -145,4 +169,22 @@ const health = computed(() => {
justify-content: flex-end;
gap: 0.5rem;
}
.fc-nav-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* The hamburger only exists on mobile; the inline links carry desktop. */
.fc-nav-burger { display: none; }
@media (max-width: 768px) {
.fc-topnav { gap: 0.5rem; padding: 0.6rem 0.75rem; }
/* Fold the link row into the hamburger menu. */
.fc-links { display: none; }
.fc-nav-burger { display: inline-flex; }
}
@media (max-width: 480px) {
/* Reclaim width on the smallest phones — the glyph alone still brands. */
.fc-brand__text { display: none; }
}
</style>
@@ -112,6 +112,16 @@ onMounted(async () => {
await store.loadDefaults()
threshold.value = store.defaults.single_color_threshold
tolerance.value = store.defaults.single_color_tolerance
// Reconnect to this rule's latest run so a scan started before navigating
// away keeps showing progress / its result on return (the scan itself runs
// backend-side regardless).
try {
const latest = await store.latestAuditForRule('single_color')
if (latest) {
audit.value = latest
if (latest.status === 'running') startPoll(latest.id)
}
} catch { /* non-fatal — card still works for a fresh scan */ }
})
onUnmounted(() => stopPoll())
@@ -97,6 +97,16 @@ let pollTimer = null
onMounted(async () => {
await store.loadDefaults()
threshold.value = store.defaults.transparency_threshold
// Reconnect to this rule's latest run so a scan started before navigating
// away keeps showing progress / its result on return (the scan itself runs
// backend-side regardless).
try {
const latest = await store.latestAuditForRule('transparency')
if (latest) {
audit.value = latest
if (latest.status === 'running') startPoll(latest.id)
}
} catch { /* non-fatal — card still works for a fresh scan */ }
})
onUnmounted(() => stopPoll())
@@ -186,7 +186,9 @@ async function onDeleteConfirm(token) {
<style scoped>
.fc-bulk-panel {
position: fixed; top: 0; right: 0;
width: 320px; height: 100vh; z-index: 1100;
/* min(320px, 90vw): on phones the panel never swallows the whole screen —
leaves a sliver of the gallery visible behind it. */
width: min(320px, 90vw); height: 100vh; z-index: 1100;
background: rgb(var(--v-theme-surface));
border-left: 1px solid rgb(var(--v-theme-surface-light));
box-shadow: -2px 0 16px rgba(0, 0, 0, 0.4);
@@ -143,4 +143,11 @@ onBeforeUnmount(() => { if (debounce) clearTimeout(debounce) })
.fc-facets__empty { color: rgb(var(--v-theme-on-surface-variant)); font-size: 0.8rem; }
.fc-facets__date { max-width: 160px; }
.fc-facets__dash { color: rgb(var(--v-theme-on-surface-variant)); }
/* Phones: let each facet group wrap and the side-by-side date inputs grow to
full width so they don't overflow a ~360px viewport. */
@media (max-width: 480px) {
.fc-facets__group { flex-wrap: wrap; }
.fc-facets__date { max-width: none; flex: 1 1 9rem; }
}
</style>
@@ -38,6 +38,12 @@
prepend-icon="mdi-account"
@click:close="clearArtist"
>{{ store.artistLabel || `Artist #${store.filter.artist_id}` }}</v-chip>
<v-chip
v-if="store.filter.similar_to"
size="small" closable color="accent" variant="tonal"
prepend-icon="mdi-image-multiple"
@click:close="clearSimilar"
>Similar to #{{ store.filter.similar_to }}</v-chip>
</div>
<v-spacer />
@@ -52,7 +58,9 @@
<v-btn value="video" size="small">Videos</v-btn>
</v-btn-toggle>
<!-- Sort is meaningless in similar-mode (results are distance-ranked). -->
<v-select
v-if="!store.filter.similar_to"
:model-value="store.filter.sort"
:items="SORTS"
density="compact" hide-details variant="outlined"
@@ -114,6 +122,7 @@ const hasActiveFilters = computed(() =>
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: {} }) }
@@ -207,7 +217,7 @@ function pushFilter(mutate) {
<style scoped>
/* The whole chrome (bar row + expandable refine panel) is one sticky,
hazey block pinned directly under the 64px TopNav and continuous with it. */
frosted block pinned directly under the 64px TopNav and continuous with it. */
.fc-filterbar-wrap {
position: sticky;
top: 64px;
@@ -217,9 +227,18 @@ function pushFilter(mutate) {
and a gap shows through when scrolled to the top. */
margin-top: -8px;
margin-bottom: 12px;
/* Same hazey obsidian (#14171A = 20,23,26) + blur as the TopNav so the two
read as one piece of chrome; content scrolls under both. */
background: rgba(20, 23, 26, 0.55);
/* EXACT same gradiated obsidian (#14171A = 20,23,26) frost as the TopNav so
the two read as one continuous piece of chrome — images scroll visibly
under both. The nav's gradient fades to transparent at ITS bottom; this
bar re-darkens at its top, so a faint seam (the page/image showing through
the nav's transparent edge) separates them when scrolled to the very top,
while under-scroll they frost as one. */
background: linear-gradient(
to bottom,
rgba(20, 23, 26, 0.92) 0%,
rgba(20, 23, 26, 0.65) 60%,
rgba(20, 23, 26, 0) 100%
);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
@@ -239,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; }
}
</style>
@@ -15,6 +15,19 @@
</div>
</template>
<!-- Similar-mode (and any non-date-grouped result set) returns no date
groups the results are ranked, not chronological. Render them as a
single flat list in their given order rather than nothing. -->
<div
v-if="!store.dateGroups.length && store.images.length"
class="fc-gallery-grid__items"
>
<GalleryItem
v-for="img in store.images"
:key="img.id" :image="img" @open="$emit('open', img.id)"
/>
</div>
<div v-if="store.loading" class="fc-gallery-grid__sentinel">
<v-progress-circular indeterminate color="accent" size="28" />
</div>
@@ -17,6 +17,10 @@
</div>
</v-card-text>
<v-card-actions>
<!-- Not every character belongs to a fandom (original characters,
unsorted, etc.). "No fandom" creates the character unassigned;
a fandom can still be set later from the chip's kebab menu. -->
<v-btn variant="text" @click="onNoFandom">No fandom</v-btn>
<v-spacer />
<v-btn @click="$emit('cancel')">Cancel</v-btn>
<v-btn :disabled="!selectedId" color="primary" rounded="pill" @click="onConfirm">Use this fandom</v-btn>
@@ -45,4 +49,9 @@ function onConfirm() {
const f = store.fandomCache.find(x => x.id === selectedId.value)
if (f) emit('confirm', f)
}
// Create the character with no fandom. Emits null so the caller knows this
// was a deliberate "unassigned", not a cancel.
function onNoFandom() {
emit('confirm', null)
}
</script>
@@ -51,6 +51,9 @@
<aside v-if="modal.current" class="fc-viewer__side">
<ProvenancePanel />
<TagPanel />
<!-- Non-blocking: fetches its own similar set after the modal is up;
collapses silently if empty/slow/failed (see RelatedStrip). -->
<RelatedStrip />
</aside>
</div>
</div>
@@ -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'])
@@ -0,0 +1,133 @@
<template>
<!-- Collapses entirely unless the source has an embedding AND we're loading
or have results — so a slow/empty/failed fetch leaves no trace and never
affects the modal. -->
<div v-if="show" class="fc-related">
<div class="fc-related__head">
<span class="fc-related__title">Related</span>
<v-btn
size="x-small" variant="text" color="accent"
:disabled="loading || !results.length"
@click="seeAll"
>See all similar</v-btn>
</div>
<div class="fc-related__row">
<template v-if="loading">
<div v-for="n in 6" :key="n" class="fc-related__skel" />
</template>
<button
v-for="img in results" :key="img.id"
class="fc-related__item" type="button"
@click="openImage(img.id)"
>
<img :src="img.thumbnail_url" :alt="`image ${img.id}`" loading="lazy" />
</button>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useApi } from '../../composables/useApi.js'
import { useModalStore } from '../../stores/modal.js'
// Deferred so the modal's main image gets network/decode priority the strip
// is a nice-to-have and must never block or slow the modal load.
const DEFER_MS = 200
const STRIP_LIMIT = 12
const api = useApi()
const router = useRouter()
const modal = useModalStore()
const results = ref([])
const loading = ref(false)
const hasEmbedding = computed(() => modal.current?.has_embedding === true)
const show = computed(() => hasEmbedding.value && (loading.value || results.value.length > 0))
let seq = 0
let timer = null
async function fetchSimilar(id) {
const mine = ++seq
loading.value = true
results.value = []
try {
const body = await api.get('/api/gallery/similar', {
params: { similar_to: id, limit: STRIP_LIMIT },
})
if (mine !== seq) return
results.value = body.images || []
} catch {
if (mine === seq) results.value = [] // quietly collapse on error
} finally {
if (mine === seq) loading.value = false
}
}
// Re-fetch whenever the modal lands on a new embedded image. modal.current is
// null while the next image loads, so this also clears the strip during
// prev/next nav and repopulates once the new payload arrives.
watch(
() => (hasEmbedding.value ? modal.current?.id : null),
(id) => {
if (timer) { clearTimeout(timer); timer = null }
seq++ // cancel any in-flight fetch
results.value = []
loading.value = false
if (!id) return
timer = setTimeout(() => fetchSimilar(id), DEFER_MS)
},
{ immediate: true },
)
onBeforeUnmount(() => { if (timer) clearTimeout(timer) })
function openImage(id) {
modal.open(id)
}
function seeAll() {
const id = modal.current?.id
if (!id) return
modal.close()
router.push({ name: 'gallery', query: { similar_to: String(id) } })
}
</script>
<style scoped>
.fc-related {
padding: 12px;
border-top: 1px solid rgb(var(--v-theme-surface-light));
}
.fc-related__head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px;
}
.fc-related__title {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-related__row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.fc-related__item {
display: block; padding: 0; border: 0; background: none;
cursor: pointer; border-radius: 4px; overflow: hidden;
aspect-ratio: 1; width: 100%;
}
.fc-related__item img {
width: 100%; height: 100%; object-fit: cover; display: block;
background: rgb(var(--v-theme-surface-light));
transition: transform 0.2s ease, filter 0.2s ease;
}
.fc-related__item:hover img { transform: scale(1.05); filter: brightness(1.1); }
.fc-related__skel {
aspect-ratio: 1; border-radius: 4px;
background: rgb(var(--v-theme-surface-light));
opacity: 0.5;
}
</style>
@@ -19,24 +19,25 @@
>
Accept
</v-btn>
<!-- Operator-flagged 2026-06-02: the kebab menu wasn't opening.
Wrapping in a <span @click.stop> matches the TagPanel chip
fix — even though there's no parent click capture here today,
the wrap is harmless and keeps both kebabs on the same
pattern. Click bubbles from the v-btn opens menu via
activator props bubble continues to span stopPropagation
halts it. -->
<span class="fc-suggestion__menu-wrap" @click.stop>
<v-menu>
<template #activator="{ props }">
<v-btn
class="fc-suggestion__menu"
icon="mdi-dots-vertical" size="small"
variant="outlined" density="compact"
:aria-label="`More actions for ${suggestion.display_name}`"
v-bind="props"
/>
</template>
<!-- Operator-flagged 2026-06-04: the kebab still wasn't opening. The
prior `#activator` + `v-bind="props"` path never toggled the menu
inside this teleported modal, while v-model-driven overlays (the
dialogs in this modal) work fine. So drive the menu explicitly:
the button toggles `menuOpen` with @click.stop (also shields any
parent), and `activator="parent"` anchors the menu for positioning
only — `:open-on-click="false"` keeps Vuetify's activator-click out
of it, so there's a single, reliable opener. -->
<span class="fc-suggestion__menu-wrap">
<v-btn
class="fc-suggestion__menu"
icon="mdi-dots-vertical" size="small"
variant="outlined" density="compact"
:aria-label="`More actions for ${suggestion.display_name}`"
@click.stop="menuOpen = !menuOpen"
/>
<v-menu
v-model="menuOpen" activator="parent" :open-on-click="false"
>
<v-list density="compact">
<v-list-item @click="$emit('alias', suggestion)">
<v-list-item-title>Treat as alias for…</v-list-item-title>
@@ -51,11 +52,12 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
const props = defineProps({ suggestion: { type: Object, required: true } })
defineEmits(['accept', 'alias', 'dismiss'])
const menuOpen = ref(false)
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
</script>
@@ -147,10 +147,14 @@ function onCreate () {
reset()
}
// fandom is null when the user picked "No fandom" — characters don't all
// belong to a fandom. The backend already accepts fandom_id: null for the
// character kind (tag.kind check + nullable fandom_id), and a fandom can be
// assigned later from the chip kebab's "Set fandom…".
function onFandomChosen (fandom) {
fandomDialog.value = false
emit('pick-new', {
name: pendingNewName, kind: 'character', fandom_id: fandom.id,
name: pendingNewName, kind: 'character', fandom_id: fandom ? fandom.id : null,
})
pendingNewName = null
reset()
+22 -14
View File
@@ -10,20 +10,24 @@
>
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<span v-if="tag.fandom_id"></span>
<!-- Operator-flagged 2026-06-02: the previous activator had
`@click.stop` directly on the v-icon, which silently
overrode Vuetify's onClick from `v-bind="mp"` — the menu
never opened. Now the v-icon receives the activator
onClick cleanly, and the wrapping span absorbs the
bubbled click so the chip's close button isn't tripped. -->
<span class="kebab-wrap" @click.stop>
<v-menu>
<template #activator="{ props: mp }">
<v-icon
v-bind="mp" size="x-small" class="ml-1"
icon="mdi-dots-vertical"
/>
</template>
<!-- Operator-flagged 2026-06-04: the `#activator` + `v-bind` menu
never opened inside this teleported modal. Drive it explicitly
instead (same mechanism as the dialogs below, which work): the
icon toggles `openTagId` with @click.stop (shielding the chip's
close button), and `activator="parent"` + `:open-on-click=false`
anchors the menu for positioning only. One tag's menu open at a
time, so a single id is enough. -->
<span class="kebab-wrap">
<v-icon
size="x-small" class="ml-1 kebab-icon"
icon="mdi-dots-vertical"
@click.stop="openTagId = openTagId === tag.id ? null : tag.id"
/>
<v-menu
:model-value="openTagId === tag.id"
activator="parent" :open-on-click="false"
@update:model-value="v => { if (!v) openTagId = null }"
>
<v-list density="compact">
<v-list-item @click="openRename(tag)">
<v-list-item-title>Rename…</v-list-item-title>
@@ -84,6 +88,9 @@ import FandomSetDialog from './FandomSetDialog.vue'
const modal = useModalStore()
const store = useTagStore()
const errorMsg = ref(null)
// Which tag chip's kebab menu is open (only one at a time). Drives each
// chip menu's v-model so opening never depends on Vuetify's activator click.
const openTagId = ref(null)
const KIND_ICONS = {
general: 'mdi-tag', character: 'mdi-account-circle',
@@ -147,4 +154,5 @@ async function onFandomUpdated() {
}
.fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; }
.kebab-wrap { display: inline-flex; align-items: center; }
.kebab-icon { cursor: pointer; }
</style>
+156 -202
View File
@@ -1,29 +1,18 @@
<template>
<v-card
:class="['fc-post-card', expanded && 'fc-post-card--expanded']"
variant="outlined"
:tabindex="expanded ? -1 : 0"
@click="onCardClick"
@keydown.enter="onCardClick"
>
<v-card class="fc-post-card" variant="outlined">
<div class="fc-post-card__head">
<!-- Posts with no live subscription have source=null (alembic
0030); show a "filesystem import" affordance instead of a
platform chip. -->
<!-- Posts with no live subscription have source=null (alembic 0030);
show a "filesystem import" affordance instead of a platform chip. -->
<v-chip size="x-small" variant="tonal">
{{ post.source?.platform ?? 'filesystem import' }}
</v-chip>
<RouterLink
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
class="fc-post-card__artist"
@click.stop
>{{ post.artist.name }}</RouterLink>
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
<span v-if="expanded && images.length" class="fc-post-card__meta">
· {{ images.length }} image{{ images.length === 1 ? '' : 's' }}
</span>
<span v-if="expanded && attachments.length" class="fc-post-card__meta">
· {{ attachments.length }} attachment{{ attachments.length === 1 ? '' : 's' }}
<span v-if="totalImages" class="fc-post-card__meta">
· {{ totalImages }} image{{ totalImages === 1 ? '' : 's' }}
</span>
<v-spacer />
<v-btn
@@ -31,140 +20,108 @@
:href="post.post_url" target="_blank" rel="noopener"
icon="mdi-open-in-new" size="x-small" variant="text"
:aria-label="`open original post on ${post.source?.platform ?? 'web'}`"
@click.stop
/>
<v-btn
:icon="expanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
size="x-small" variant="text"
:aria-label="expanded ? 'Collapse post' : 'Expand post'"
@click.stop="toggleExpanded"
/>
</div>
<!-- Compact body: collapsed card. Hero + thumb rail + truncated text. -->
<div v-if="!expanded" class="fc-post-card__body">
<div class="fc-post-card__body">
<div class="fc-post-card__media">
<template v-if="images.length">
<div class="fc-post-card__hero">
<img :src="hero.thumbnail_url" :alt="`hero thumbnail`" loading="lazy" />
</div>
<div v-if="rail.length" class="fc-post-card__rail">
<div v-for="t in rail" :key="t.image_id" class="fc-post-card__rail-cell">
<img :src="t.thumbnail_url" :alt="`thumbnail`" loading="lazy" />
</div>
<div v-if="moreCount > 0" class="fc-post-card__rail-more">
+{{ moreCount }}
</div>
<!-- Images open the post-scoped image modal (look bigger + arrow
through ALL the post's images) the card never expands. -->
<button
type="button" class="fc-post-card__hero"
aria-label="Open images" @click="openModal(hero.image_id)"
>
<img :src="hero.thumbnail_url" alt="hero thumbnail" loading="lazy" />
</button>
<div v-if="rail.length || moreCount" class="fc-post-card__rail">
<button
v-for="t in rail" :key="t.image_id" type="button"
class="fc-post-card__rail-cell"
aria-label="Open image" @click="openModal(t.image_id)"
>
<img :src="t.thumbnail_url" alt="thumbnail" loading="lazy" />
</button>
<button
v-if="moreCount > 0" type="button"
class="fc-post-card__rail-more"
:aria-label="`Open ${moreCount} more images`"
@click="openModalAtMore"
>+{{ moreCount }}</button>
</div>
</template>
<PostEmptyThumbs v-else />
</div>
<div class="fc-post-card__text">
<h3 v-if="plainTitle" class="fc-post-card__title">
{{ plainTitle }}
</h3>
<h3 v-if="plainTitle" class="fc-post-card__title">{{ plainTitle }}</h3>
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
Post {{ post.external_post_id }}
</h3>
<p v-if="post.description_plain" class="fc-post-card__desc">
{{ post.description_plain }}
</p>
<p
v-if="hasDescription" ref="descEl"
class="fc-post-card__desc"
:class="{ 'fc-post-card__desc--clamped': !descExpanded }"
>{{ descText }}</p>
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
(no description)
</p>
<div v-if="post.attachments?.length" class="fc-post-card__atts">
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
{{ post.attachments.length }} attachment{{ post.attachments.length === 1 ? '' : 's' }}
</div>
</div>
</div>
<!-- The ONLY in-place expansion: the post text, and only when it's
actually truncated (server flag or a CSS-clamp overflow). -->
<button
v-if="canExpand" type="button" class="fc-post-card__more"
@click="toggleDesc"
>{{ descExpanded ? 'Show less' : 'Show more' }}</button>
<!-- Expanded body: title, full mosaic, full sanitized HTML description,
attachments. Lazy-loaded detail via getPostFull. -->
<div v-else class="fc-post-card__expanded">
<h2 v-if="plainTitle" class="fc-post-card__title-full">
{{ plainTitle }}
</h2>
<h2 v-else class="fc-post-card__title-full fc-post-card__title--missing">
Post {{ post.external_post_id }}
</h2>
<section v-if="images.length" class="fc-post-card__sec">
<PostImageGrid :thumbnails="images" />
<div v-if="!detailLoaded" class="fc-post-card__loading-hint">
Loading full image list
</div>
</section>
<section v-if="descriptionHtml" class="fc-post-card__sec">
<div class="fc-post-card__desc-full" v-html="descriptionHtml" />
</section>
<section v-else-if="detailLoaded" class="fc-post-card__sec">
<p class="fc-post-card__desc fc-post-card__desc--missing">(no description)</p>
</section>
<section v-if="attachments.length" class="fc-post-card__sec">
<h3 class="fc-post-card__h3">Attachments</h3>
<div class="fc-post-card__atts-full">
<div v-if="attachments.length" class="fc-post-card__atts">
<a
v-for="att in attachments" :key="att.id"
:href="att.download_url" download
class="fc-post-card__att"
@click.stop
:href="att.download_url" download class="fc-post-card__att"
>
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
<span>{{ att.original_filename }}</span>
<span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span>
</a>
</div>
</section>
</div>
</div>
</v-card>
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { useModalStore } from '../../stores/modal.js'
import { usePostsStore } from '../../stores/posts.js'
import { sanitizeHtml, toPlainText } from '../../utils/htmlSanitize.js'
import { toPlainText } from '../../utils/htmlSanitize.js'
import PostEmptyThumbs from './PostEmptyThumbs.vue'
import PostImageGrid from './PostImageGrid.vue'
const props = defineProps({
post: { type: Object, required: true },
})
const postsStore = usePostsStore()
const modal = useModalStore()
// Per-card expand state. No global modal — each PostCard owns its own
// view-mode and lazy-loaded detail.
const expanded = ref(false)
// Full detail (uncapped thumbnails + full description), fetched lazily — only
// when opening the modal for a post with >6 images, or expanding a
// server-truncated description.
const detail = ref(null)
const detailLoaded = ref(false)
const detailError = ref(null)
// When expanded + detail loaded, prefer the uncapped detail thumbnails +
// full description. Falls back to feed shape if detail fetch is in flight
// or failed.
const merged = computed(() => detail.value || props.post)
const images = computed(() => merged.value.thumbnails || [])
const attachments = computed(() => merged.value.attachments || [])
// Titles can arrive as stored HTML (e.g. "<strong>…</strong>"); render as
// plain text — the CSS makes the title bold.
const attachments = computed(() => props.post.attachments || [])
const images = computed(() => props.post.thumbnails || [])
const totalImages = computed(() => images.value.length + (props.post.thumbnails_more || 0))
const plainTitle = computed(() => toPlainText(props.post.post_title))
// Compact-view hero+rail derived from the feed-shape (capped 6).
const hero = computed(() => props.post.thumbnails?.[0])
const rail = computed(() => (props.post.thumbnails || []).slice(1, 4))
const hero = computed(() => images.value[0])
const rail = computed(() => images.value.slice(1, 4))
const visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
const moreCount = computed(() => {
const more = props.post.thumbnails_more || 0
const railLen = rail.value.length
const extraShown = Math.max(0, (props.post.thumbnails?.length || 0) - 1 - railLen)
const extraShown = Math.max(0, images.value.length - visibleCount.value)
return more + extraShown
})
@@ -180,49 +137,77 @@ const relativeDate = computed(() => {
return new Date(sortDateIso.value).toLocaleDateString()
})
const descriptionHtml = computed(() => {
// Detail endpoint returns description_full as plain text (the service
// uses html_to_plain on the stored description). Render plain text in
// <p> wrappers; sanitize defensively in case the backend ever returns
// raw HTML.
const raw = merged.value.description_full || merged.value.description_plain
if (!raw) return ''
if (/[<>]/.test(raw)) return sanitizeHtml(raw)
const esc = raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
return esc
.split(/\n\s*\n/)
.map((p) => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('')
})
async function loadDetailIfNeeded () {
if (detailLoaded.value || detail.value) return
try {
detail.value = await postsStore.getPostFull(props.post.id)
detailLoaded.value = true
} catch (e) {
detailError.value = e.message
// Leave merged on feed-shape; the card still renders the truncated
// body so the operator isn't staring at a blank panel.
// --- images → post-scoped modal ---------------------------------------
async function fullImageIds () {
// Feed caps thumbnails at 6; load detail for the complete id list only when
// there are more, so the modal can arrow through ALL of the post's images.
if ((props.post.thumbnails_more || 0) === 0) {
return images.value.map((t) => t.image_id)
}
if (!detail.value) {
try {
detail.value = await postsStore.getPostFull(props.post.id)
} catch { /* fall back to the capped feed list */ }
}
return (detail.value?.thumbnails || images.value).map((t) => t.image_id)
}
function toggleExpanded () {
expanded.value = !expanded.value
if (expanded.value) loadDetailIfNeeded()
async function openModal (imageId) {
modal.open(imageId, { postImageIds: await fullImageIds() })
}
function onCardClick (e) {
// Inner interactive elements use @click.stop so they never reach here.
// Whole-card click expands a collapsed card; collapsing is chevron-only
// so a mosaic-image click on an expanded card can never accidentally
// collapse the surrounding card.
if (expanded.value) return
expanded.value = true
loadDetailIfNeeded()
async function openModalAtMore () {
const ids = await fullImageIds()
const first = ids[visibleCount.value] ?? ids[0]
if (first != null) modal.open(first, { postImageIds: ids })
}
// --- description "Show more" (text-only, in place, only when truncated) ----
const descExpanded = ref(false)
const cssOverflow = ref(false)
const descEl = ref(null)
const hasDescription = computed(() => !!props.post.description_plain)
const fullDescription = computed(() => detail.value?.description_full || null)
const descText = computed(() =>
descExpanded.value
? (fullDescription.value || props.post.description_plain)
: props.post.description_plain,
)
// Show the toggle iff the server truncated the text OR the clamp is cutting it.
const canExpand = computed(
() => props.post.description_truncated === true || cssOverflow.value,
)
function measureOverflow () {
const el = descEl.value
cssOverflow.value = !!el && el.scrollHeight > el.clientHeight + 1
}
let ro = null
onMounted(() => {
nextTick(measureOverflow)
// Re-measure when the card resizes (the container-query clamp differs by
// width). Guarded for happy-dom / older runtimes without ResizeObserver.
if (typeof ResizeObserver !== 'undefined' && descEl.value) {
ro = new ResizeObserver(() => { if (!descExpanded.value) measureOverflow() })
ro.observe(descEl.value)
}
})
onBeforeUnmount(() => { if (ro) { ro.disconnect(); ro = null } })
async function toggleDesc () {
if (!descExpanded.value) {
if (props.post.description_truncated && !fullDescription.value && !detail.value) {
try {
detail.value = await postsStore.getPostFull(props.post.id)
} catch { /* render the truncated text rather than nothing */ }
}
descExpanded.value = true
} else {
descExpanded.value = false
nextTick(measureOverflow)
}
}
function formatBytes (n) {
@@ -239,20 +224,6 @@ function formatBytes (n) {
padding: 1rem;
margin-bottom: 1rem;
container-type: inline-size;
transition: border-color 0.15s ease;
}
.fc-post-card:not(.fc-post-card--expanded) {
cursor: pointer;
}
.fc-post-card:not(.fc-post-card--expanded):hover {
border-color: rgb(var(--v-theme-accent));
}
.fc-post-card:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 2px;
}
.fc-post-card--expanded {
border-color: rgb(var(--v-theme-accent) / 0.6);
}
.fc-post-card__head {
@@ -273,7 +244,6 @@ function formatBytes (n) {
.fc-post-card__date,
.fc-post-card__meta { white-space: nowrap; }
/* ---- COMPACT BODY ---- */
.fc-post-card__body {
display: flex;
flex-direction: column;
@@ -288,6 +258,13 @@ function formatBytes (n) {
.fc-post-card__text { flex: 1 1 0; min-width: 0; }
}
/* Image tiles are buttons (open the post-scoped modal) — reset button chrome. */
.fc-post-card__hero,
.fc-post-card__rail-cell,
.fc-post-card__rail-more {
display: block; padding: 0; border: 0; background: none;
cursor: pointer;
}
.fc-post-card__hero {
width: 100%;
aspect-ratio: 16 / 10;
@@ -297,10 +274,13 @@ function formatBytes (n) {
.fc-post-card__hero img {
width: 100%; height: 100%;
object-fit: cover; display: block;
transition: transform 0.2s ease, filter 0.2s ease;
}
.fc-post-card__hero:hover img,
.fc-post-card__rail-cell:hover img { transform: scale(1.03); filter: brightness(1.08); }
.fc-post-card__rail {
display: flex; gap: 6px; margin-top: 6px;
display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap;
}
.fc-post-card__rail-cell {
width: 80px; height: 80px;
@@ -318,6 +298,10 @@ function formatBytes (n) {
color: rgb(var(--v-theme-on-surface-variant));
font-size: 0.85rem;
}
.fc-post-card__rail-more:hover {
border-color: rgb(var(--v-theme-accent));
color: rgb(var(--v-theme-accent));
}
.fc-post-card__title {
font-family: 'Fraunces', Georgia, serif;
@@ -346,67 +330,36 @@ function formatBytes (n) {
font-size: 0.9rem;
line-height: 1.5;
color: rgb(var(--v-theme-on-surface));
margin: 0 0 12px 0;
margin: 0;
white-space: pre-wrap;
}
/* Clamp ONLY while collapsed; expanding drops the clamp to show it all. */
.fc-post-card__desc--clamped {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
@container (min-width: 800px) {
.fc-post-card__desc--clamped { -webkit-line-clamp: 5; }
}
.fc-post-card__desc--missing {
font-style: italic;
color: rgb(var(--v-theme-on-surface-variant));
}
@container (min-width: 800px) {
.fc-post-card__desc { -webkit-line-clamp: 5; }
.fc-post-card__more {
margin-top: 6px;
padding: 0;
background: none; border: 0; cursor: pointer;
color: rgb(var(--v-theme-accent));
font-size: 0.85rem; font-weight: 600;
}
.fc-post-card__more:hover { text-decoration: underline; }
.fc-post-card__atts {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
/* ---- EXPANDED BODY ---- */
.fc-post-card__expanded {
display: flex;
flex-direction: column;
gap: 20px;
}
.fc-post-card__title-full {
font-family: 'Fraunces', Georgia, serif;
font-size: 22px;
font-weight: 700;
margin: 0;
color: rgb(var(--v-theme-on-surface));
}
@container (min-width: 800px) {
.fc-post-card__title-full { font-size: 26px; }
}
.fc-post-card__sec { margin: 0; }
.fc-post-card__h3 {
font-family: 'Fraunces', Georgia, serif;
font-size: 16px;
font-weight: 500;
margin: 0 0 8px 0;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-card__loading-hint {
margin-top: 8px;
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__desc-full {
font-size: 0.95rem;
line-height: 1.55;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-card__desc-full :deep(p) { margin: 0 0 12px 0; }
.fc-post-card__desc-full :deep(a) { color: rgb(var(--v-theme-accent)); }
.fc-post-card__atts-full {
display: flex; flex-wrap: wrap; gap: 8px;
margin-top: 12px;
}
.fc-post-card__att {
display: inline-flex;
@@ -423,5 +376,6 @@ function formatBytes (n) {
color: rgb(var(--v-theme-accent));
border-color: rgb(var(--v-theme-accent));
}
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
</style>
@@ -1,63 +0,0 @@
<template>
<div class="fc-post-grid">
<button
v-for="(t, idx) in thumbnails"
:key="t.image_id"
type="button"
class="fc-post-grid__cell"
:aria-label="`Open image ${idx + 1} of ${thumbnails.length}`"
@click="openImage(t.image_id, idx)"
>
<img
:src="t.thumbnail_url"
:alt="`thumbnail ${idx + 1}`"
loading="lazy"
/>
</button>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useModalStore } from '../../stores/modal.js'
const props = defineProps({
thumbnails: { type: Array, required: true }, // [{ image_id, thumbnail_url, ... }]
})
const modal = useModalStore()
const imageIds = computed(() => props.thumbnails.map(t => t.image_id))
function openImage (id, idx) {
modal.open(id, { postImageIds: imageIds.value, initialIndex: idx })
}
</script>
<style scoped>
.fc-post-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 6px;
}
.fc-post-grid__cell {
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 4px;
cursor: pointer;
border: 0;
padding: 0;
background: rgb(var(--v-theme-background));
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.fc-post-grid__cell:hover {
transform: scale(1.02);
box-shadow: 0 0 0 2px rgb(var(--v-theme-accent));
}
.fc-post-grid__cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
</style>
@@ -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%; }
}
</style>
@@ -2,6 +2,42 @@
<v-card>
<v-card-title>Import filters</v-card-title>
<v-card-text v-if="store.settings">
<!-- Near-duplicate dedup sensitivity, hoisted to the top: it's the
most-asked knob too loose and edits/variants of the same image
get dropped as duplicates on import. Slider with sane labelled
stops for the gist + a number field for precision; both bind the
same phash_threshold. -->
<div class="fc-phash">
<div class="fc-phash__title">Near-duplicate sensitivity</div>
<div class="fc-help mb-1">
How aggressively imports merge look-alike images (perceptual-hash
distance). <strong>Lower it if edits/variants of the same image are
being dropped as duplicates;</strong> raise it to collapse more
look-alikes. Applies to new imports.
</div>
<v-row align="center" no-gutters>
<v-col cols="12" sm="9">
<v-slider
v-model="local.phash_threshold"
:min="0" :max="16" :step="1"
:ticks="PHASH_TICKS" show-ticks="always" tick-size="4"
thumb-label color="accent" hide-details
class="fc-phash__slider"
@end="save"
/>
</v-col>
<v-col cols="8" sm="3" class="ps-sm-4 mt-2 mt-sm-0">
<v-text-field
v-model.number="local.phash_threshold"
label="Distance" type="number" min="0"
density="compact" hide-details @blur="save"
/>
</v-col>
</v-row>
</div>
<v-divider class="my-5" />
<v-row>
<v-col cols="12" sm="6">
<v-text-field
@@ -41,14 +77,6 @@
:disabled="!local.skip_single_color" @end="save"
/>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model.number="local.phash_threshold"
label="Perceptual-hash threshold" type="number" min="0"
density="compact" hide-details @blur="save"
/>
<div class="fc-help">Higher = looser near-duplicate matching.</div>
</v-col>
</v-row>
<v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable>
@@ -66,6 +94,9 @@ import { reactive, watch } from 'vue'
import { useImportStore } from '../../stores/import.js'
const store = useImportStore()
// Labelled stops so the less-initiated get the gist without knowing what a
// Hamming distance is. 0 = byte-for-byte only; 10 = the shipped default.
const PHASH_TICKS = { 0: 'Exact', 4: 'Strict', 10: 'Default', 16: 'Loose' }
// Downloader + schedule-defaults fields moved to
// /subscriptions?tab=settings (operator decision 2026-05-27). This form
// now only owns image-import filters.
@@ -89,4 +120,11 @@ async function save() {
color: rgb(var(--v-theme-on-surface-variant));
margin-top: 2px;
}
.fc-phash__title {
font-size: 0.95rem;
font-weight: 600;
color: rgb(var(--v-theme-on-surface));
}
/* Headroom so the tick labels (Exact/Strict/Default/Loose) aren't clipped. */
.fc-phash__slider { margin-bottom: 18px; }
</style>
@@ -89,6 +89,53 @@
@click="onKindCommit"
>Delete {{ kindPreview.count }} legacy tag(s)</v-btn>
</div>
<v-divider class="my-5" />
<p class="text-body-2 mb-2">
<strong class="text-error">Reset content tagging.</strong>
Deletes every <code>general</code> and <code>character</code> tag and
removes them from every image, so you can re-tag from scratch with the
auto-suggest. <strong>Fandoms and series (with their page order) are
kept</strong>, and each image's saved predictions are untouched open
an image and its suggestions reappear.
</p>
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
Irreversible there's no undo except restoring a DB backup.
Back one up first (Settings → Maintenance → Backup).
</v-alert>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingResetPreview"
class="mb-3"
@click="onResetPreview"
>Preview content-tag reset</v-btn>
<div v-if="resetPreview">
<p class="text-body-2 mb-2">
<strong>{{ resetPreview.count }}</strong> content tag(s)
<span v-for="(n, k) in resetPreview.by_kind" :key="k" class="fc-muted">
({{ k }}: {{ n }})&nbsp;
</span>
across <strong>{{ resetPreview.applications }}</strong> image
application(s).
</p>
<div v-if="resetPreview.sample_names?.length" class="fc-name-grid mb-3">
<span v-for="n in resetPreview.sample_names" :key="n" class="fc-name">
{{ n }}
</span>
</div>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-alert"
:disabled="!resetPreview.count"
:loading="resetCommitting"
@click="onResetCommit"
>Delete {{ resetPreview.count }} content tag(s) +
{{ resetPreview.applications }} application(s)</v-btn>
</div>
</v-card-text>
</v-card>
</template>
@@ -105,6 +152,9 @@ const committing = ref(false)
const kindPreview = ref(null)
const loadingKindPreview = ref(false)
const kindCommitting = ref(false)
const resetPreview = ref(null)
const loadingResetPreview = ref(false)
const resetCommitting = ref(false)
async function onPreview() {
loadingPreview.value = true
@@ -143,6 +193,25 @@ async function onKindCommit() {
kindCommitting.value = false
}
}
async function onResetPreview() {
loadingResetPreview.value = true
try {
resetPreview.value = await store.resetContentTagging({ dryRun: true })
} finally {
loadingResetPreview.value = false
}
}
async function onResetCommit() {
resetCommitting.value = true
try {
await store.resetContentTagging({ dryRun: false })
resetPreview.value = { count: 0, by_kind: {}, applications: 0, sample_names: [] }
} finally {
resetCommitting.value = false
}
}
</script>
<style scoped>
@@ -0,0 +1,107 @@
<template>
<div class="fc-source-card">
<div class="fc-source-card__top">
<SourceHealthDot :source="source" :warning-threshold="warningThreshold" />
<v-chip size="x-small" variant="tonal" label>{{ source.platform }}</v-chip>
<v-spacer />
<v-switch
:model-value="source.enabled"
density="compact" hide-details color="accent"
@click.stop
@update:model-value="onToggleEnabled"
/>
</div>
<a
:href="source.url" target="_blank" rel="noopener"
class="fc-source-card__url" @click.stop
>{{ source.url }}</a>
<div class="fc-source-card__meta">
<span>Last {{ formatRelative(source.last_checked_at) }}</span>
<span>Next {{ formatRelative(source.next_check_at, { future: true }) }}</span>
<v-chip
v-if="(source.consecutive_failures || 0) > 0"
size="x-small" color="error" variant="tonal" label
>{{ source.consecutive_failures }} err</v-chip>
<v-chip
v-else-if="(source.backfill_runs_remaining || 0) > 0"
size="x-small" color="info" variant="tonal" label
>backfill ({{ source.backfill_runs_remaining }}×)</v-chip>
</div>
<div class="fc-source-card__actions">
<v-btn
size="x-small" variant="text" :loading="checking"
@click.stop="$emit('check', source)"
>
<v-icon>mdi-play</v-icon>
<v-tooltip activator="parent" location="top">Check now</v-tooltip>
</v-btn>
<v-btn
size="x-small" variant="text"
:disabled="(source.backfill_runs_remaining || 0) > 0"
@click.stop="$emit('backfill', source)"
>
<v-icon>mdi-magnify-scan</v-icon>
<v-tooltip activator="parent" location="top">Deep scan</v-tooltip>
</v-btn>
<v-btn size="x-small" variant="text" @click.stop="$emit('edit', source)">
<v-icon>mdi-pencil</v-icon>
<v-tooltip activator="parent" location="top">Edit</v-tooltip>
</v-btn>
<v-btn
size="x-small" variant="text" color="error"
@click.stop="$emit('remove', source)"
>
<v-icon>mdi-close</v-icon>
<v-tooltip activator="parent" location="top">Remove</v-tooltip>
</v-btn>
</div>
</div>
</template>
<script setup>
import SourceHealthDot from './SourceHealthDot.vue'
import { formatRelative } from '../../utils/date.js'
// Mobile-stacked equivalent of SourceRow (the desktop <tr>) — same data and
// emits, but laid out vertically so the wide source columns never force the
// lateral scroll the operator flagged on phones.
const props = defineProps({
source: { type: Object, required: true },
checking: { type: Boolean, default: false },
warningThreshold: { type: Number, default: 5 },
})
const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill'])
function onToggleEnabled(value) {
emit('toggle', { source: props.source, enabled: value })
}
</script>
<style scoped>
.fc-source-card {
padding: 8px 10px;
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 6px;
background: rgb(var(--v-theme-surface));
display: flex; flex-direction: column; gap: 6px;
}
.fc-source-card__top { display: flex; align-items: center; gap: 8px; }
.fc-source-card__url {
color: rgb(var(--v-theme-on-surface-variant));
text-decoration: none;
font-size: 0.8rem;
word-break: break-all;
}
.fc-source-card__url:hover { color: rgb(var(--v-theme-accent)); }
.fc-source-card__meta {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px;
font-size: 0.78rem; color: rgb(var(--v-theme-on-surface-variant));
font-variant-numeric: tabular-nums;
}
.fc-source-card__actions {
display: flex; gap: 2px; justify-content: flex-end;
}
</style>
@@ -26,14 +26,14 @@
:items="STATUS_OPTIONS"
:disabled="needsAttention"
density="compact" variant="outlined" hide-details
style="max-width: 180px"
class="fc-subs__status"
/>
<v-text-field
v-model="search"
density="compact" variant="outlined" hide-details clearable
prepend-inner-icon="mdi-magnify"
placeholder="Search subscriptions"
style="max-width: 320px"
class="fc-subs__search"
/>
</div>
@@ -75,7 +75,7 @@
<p v-else>No subscriptions match the current filter.</p>
</div>
<v-card v-else class="fc-subs__card" variant="outlined">
<v-card v-else-if="!isMobile" class="fc-subs__card" variant="outlined">
<v-data-table
:headers="headers"
:items="filteredGroups"
@@ -189,6 +189,76 @@
</v-data-table>
</v-card>
<!-- Mobile: compact cards (several fit per screen), expanding to STACKED
source cards so the wide source columns never force lateral scroll. -->
<div v-else class="fc-subs__mlist">
<div
v-for="item in filteredGroups" :key="item.key"
class="fc-subs__mcard"
>
<div class="fc-subs__mhead" @click="toggleExpand(item)">
<v-checkbox-btn
:model-value="isSelected(item)" density="compact" hide-details
@click.stop @update:model-value="toggleSelect(item)"
/>
<span class="fc-subs__name">{{ item.artist.name }}</span>
<v-spacer />
<SourceHealthDot
v-if="item.worstSource"
:source="item.worstSource" :warning-threshold="failureThreshold"
/>
<v-icon size="small">
{{ isExpanded(item) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}
</v-icon>
</div>
<div class="fc-subs__mmeta">
<PlatformChip
v-for="p in item.platforms" :key="p" :platform="p" size="x-small"
/>
<span class="fc-subs__mmeta-text">
{{ item.sources.length }} src · {{ formatRelative(item.lastActivity) }}
</span>
</div>
<div v-if="isExpanded(item)" class="fc-subs__mbody">
<div class="fc-subs__mactions">
<v-btn
size="small" variant="text" :loading="anyChecking(item.sources)"
@click="checkAll(item)"
>
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="top">Check all</v-tooltip>
</v-btn>
<v-btn size="small" variant="text" @click="openAddSource(item.artist)">
<v-icon>mdi-plus</v-icon>
<v-tooltip activator="parent" location="top">Add source</v-tooltip>
</v-btn>
<v-btn size="small" variant="text" :to="`/posts?artist_id=${item.artist.id}`">
<v-icon>mdi-rss</v-icon>
<v-tooltip activator="parent" location="top">View posts</v-tooltip>
</v-btn>
<v-btn size="small" variant="text" :to="`/artist/${item.artist.slug}`">
<v-icon>mdi-account</v-icon>
<v-tooltip activator="parent" location="top">Artist page</v-tooltip>
</v-btn>
</div>
<SourceCard
v-for="s in item.sources" :key="s.id" :source="s"
:checking="store.checkingIds.has(s.id)"
:warning-threshold="failureThreshold"
@edit="openEditSource"
@remove="removeSource"
@toggle="toggleSourceEnabled"
@check="onCheck"
@backfill="onBackfill"
/>
<div v-if="item.sources.length === 0" class="fc-subs__sources-empty">
No sources yet. Tap + to add one.
</div>
</div>
</div>
</div>
<SourceFormDialog
v-model="showSourceDialog"
:source="editingSource"
@@ -202,11 +272,13 @@
<script setup>
import { toast } from '../../utils/toast.js'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useDisplay } from 'vuetify'
import { useRoute, useRouter } from 'vue-router'
import { useSourcesStore } from '../../stores/sources.js'
import { usePlatformsStore } from '../../stores/platforms.js'
import { useImportStore } from '../../stores/import.js'
import SourceRow from './SourceRow.vue'
import SourceCard from './SourceCard.vue'
import SourceHealthDot from './SourceHealthDot.vue'
import SourceFormDialog from './SourceFormDialog.vue'
import ArtistCreateDialog from './ArtistCreateDialog.vue'
@@ -230,6 +302,10 @@ const STATUS_OPTIONS = [
const route = useRoute()
const router = useRouter()
const display = useDisplay()
// Match the 600px breakpoint the rest of the hub uses; below it the table is
// replaced by the custom compact-card list.
const isMobile = computed(() => display.width.value < 600)
const store = useSourcesStore()
const platformsStore = usePlatformsStore()
const importStore = useImportStore()
@@ -239,6 +315,19 @@ const statusFilter = ref('all')
const needsAttention = ref(false)
const expanded = ref([])
const selected = ref([])
// Mobile card list drives the same `selected`/`expanded` key arrays the
// desktop v-data-table binds, so selection + bulk actions work identically.
function _toggleKey(arr, key) {
const i = arr.value.indexOf(key)
if (i === -1) arr.value = [...arr.value, key]
else arr.value = arr.value.filter((k) => k !== key)
}
function isSelected(item) { return selected.value.includes(item.key) }
function toggleSelect(item) { _toggleKey(selected, item.key) }
function isExpanded(item) { return expanded.value.includes(item.key) }
function toggleExpand(item) { _toggleKey(expanded, item.key) }
const showSourceDialog = ref(false)
const editingSource = ref(null)
const editingArtist = ref(null)
@@ -568,6 +657,18 @@ 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; }
/* The table renders as stacked cards (mobile-breakpoint); reclaim the
desktop indent on the expanded sources detail (it keeps its own
horizontal scroll for the wide source columns). */
.fc-subs__sources-cell { padding-left: 0.5rem !important; }
}
.fc-subs__loading, .fc-subs__empty {
display: flex; justify-content: center; padding: 2rem;
color: rgb(var(--v-theme-on-surface-variant));
@@ -575,6 +676,30 @@ async function bulkDelete() {
.fc-subs__card {
background: rgb(var(--v-theme-surface));
}
/* Mobile compact-card list (replaces the data-table <600px). */
.fc-subs__mlist { display: flex; flex-direction: column; gap: 8px; }
.fc-subs__mcard {
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 8px;
background: rgb(var(--v-theme-surface));
padding: 8px 10px;
}
.fc-subs__mhead { display: flex; align-items: center; gap: 6px; cursor: pointer; }
.fc-subs__mmeta {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
margin-top: 4px;
}
.fc-subs__mmeta-text {
font-size: 0.78rem; color: rgb(var(--v-theme-on-surface-variant));
font-variant-numeric: tabular-nums;
}
.fc-subs__mbody {
margin-top: 8px; padding-top: 8px;
border-top: 1px solid rgb(var(--v-theme-surface-light));
display: flex; flex-direction: column; gap: 6px;
}
.fc-subs__mactions { display: flex; gap: 2px; }
.fc-subs__name { font-weight: 600; }
.fc-subs__chips {
display: flex; flex-wrap: wrap; gap: 4px;
+16
View File
@@ -127,6 +127,21 @@ export const useAdminStore = defineStore('admin', () => {
}
}
// Destructive: deletes ALL general + character tags so the operator can
// re-tag from scratch via auto-suggest. fandom + series preserved.
async function resetContentTagging({ dryRun = true } = {}) {
lastError.value = null
try {
return await api.post(
'/api/admin/tags/reset-content',
{ body: { dry_run: dryRun } },
)
} catch (e) {
lastError.value = e.message
throw e
}
}
// --- Task progress polling (taps FC-3i activity dashboard) --------
/**
@@ -162,6 +177,7 @@ export const useAdminStore = defineStore('admin', () => {
tagUsageCount,
pruneUnusedTags,
purgeLegacyTags,
resetContentTagging,
pollTaskUntilDone,
}
})
+9 -1
View File
@@ -55,6 +55,14 @@ export const useCleanupStore = defineStore('cleanup', () => {
return body.runs
}
// The most recent audit run for a given rule, or null. Cards call this on
// mount to reconnect to a scan that's still running (or to show the last
// completed result) after the user navigates away and back.
async function latestAuditForRule(rule) {
const body = await api.get('/api/cleanup/audit', { params: { rule, limit: 1 } })
return (body.runs && body.runs[0]) || null
}
async function applyAudit(id, confirm) {
return await api.post(`/api/cleanup/audit/${id}/apply`, { body: { confirm } })
}
@@ -67,6 +75,6 @@ export const useCleanupStore = defineStore('cleanup', () => {
defaults, recentRuns,
loadDefaults,
previewMinDim, deleteMinDim,
startAudit, getAudit, loadHistory, applyAudit, cancelAudit,
startAudit, getAudit, loadHistory, latestAuditForRule, applyAudit, cancelAudit,
}
})
+47 -3
View File
@@ -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
}
+3 -3
View File
@@ -18,9 +18,9 @@ export const useModalStore = defineStore('modal', () => {
const inflight = useInflightToken()
// Post-scoped cycle. When set, prev/next cycles within this array
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When
// null, prev/next falls back to current.value.neighbors (the
// gallery-store-driven /api/gallery/image/<id> neighbors).
// (used by PostCard image clicks — the modal is scoped to that post's
// images). When null, prev/next falls back to current.value.neighbors
// (the gallery-store-driven /api/gallery/image/<id> neighbors).
const postImageIds = ref(null)
const postImageIndex = ref(0)
+2 -1
View File
@@ -24,7 +24,8 @@ import { preloadImage } from '../utils/preloadImage.js'
// fetch + decode (a cold TABLESAMPLE is a separate, query-side concern);
// everything after it is buffer-smoothed.
const PAGE = 3
const CADENCE_MS = 80 // floor between fully-loaded reveals
const CADENCE_MS = 160 // floor between fully-loaded reveals (doubled
// 2026-06-04 — slower, more deliberate cadence)
const PRIME = 6 // items buffered before the drain starts
const BUFFER_TARGET = 30 // producer tops the queue up to this
const BUFFER_MIN = 12 // ...and refills once the queue dips below this
+3 -1
View File
@@ -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 {
+9 -1
View File
@@ -16,7 +16,10 @@
<EmptyState v-if="store.isEmpty" />
<GalleryGrid v-else @open="openImage" />
</div>
<TimelineSidebar v-if="store.images.length > 0" class="fc-gallery-layout__sidebar" />
<TimelineSidebar
v-if="store.images.length > 0 && store.filter.similar_to == null"
class="fc-gallery-layout__sidebar"
/>
</div>
<BulkEditorPanel />
@@ -68,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; }
}
</style>
+6 -5
View File
@@ -44,14 +44,15 @@
</v-window-item>
<v-window-item value="import">
<!-- Order: trigger → recent tasks → filters. Tasks sit directly
below the trigger so operator sees hit/miss feedback without
scrolling past the filter card (operator-flagged 2026-05-25). -->
<!-- Order: filters → trigger → recent tasks. Filters hoisted above the
trigger (operator-flagged 2026-06-04); the task list stays
directly below the trigger so hit/miss feedback is adjacent to the
button that produced it (operator-flagged 2026-05-25). -->
<ImportFiltersForm />
<v-divider class="my-6" />
<ImportTriggerPanel />
<v-divider class="my-6" />
<ImportTaskList />
<v-divider class="my-6" />
<ImportFiltersForm />
</v-window-item>
<v-window-item value="cleanup">
@@ -0,0 +1,47 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import GalleryGrid from '../../src/components/gallery/GalleryGrid.vue'
import { useGalleryStore } from '../../src/stores/gallery.js'
import { freshPinia } from '../support/mountComponent.js'
const GIStub = { name: 'GalleryItem', props: ['image'], template: '<div class="gi" />' }
function mountGrid(pinia) {
return mount(GalleryGrid, {
global: {
plugins: [pinia],
stubs: { GalleryItem: GIStub, RouterLink: { template: '<a><slot /></a>' } },
},
})
}
describe('GalleryGrid', () => {
it('renders a flat ranked list when there are no date groups (similar mode)', () => {
const pinia = freshPinia()
const store = useGalleryStore()
// similar-mode shape: images present, date_groups empty, no cursor.
store.images = [
{ id: 1, thumbnail_url: '/a' },
{ id: 2, thumbnail_url: '/b' },
{ id: 3, thumbnail_url: '/c' },
]
store.dateGroups = []
const w = mountGrid(pinia)
expect(w.findAll('.gi').length).toBe(3)
})
it('renders grouped by date when date groups are present', () => {
const pinia = freshPinia()
const store = useGalleryStore()
store.images = [
{ id: 1, thumbnail_url: '/a' },
{ id: 2, thumbnail_url: '/b' },
]
store.dateGroups = [{ year: 2026, month: 6, image_ids: [1, 2] }]
const w = mountGrid(pinia)
expect(w.find('.fc-gallery-grid__date-header').exists()).toBe(true)
expect(w.findAll('.gi').length).toBe(2)
})
})
@@ -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)
})
})
+48 -13
View File
@@ -1,26 +1,61 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import PostCard from '../../src/components/posts/PostCard.vue'
import { useModalStore } from '../../src/stores/modal.js'
import { freshPinia, mountComponent } from '../support/mountComponent.js'
const now = new Date().toISOString()
const BASE = {
id: 1, external_post_id: 'P1',
post_title: '<strong>Hello World</strong>',
post_url: 'https://x/1', post_date: now, downloaded_at: now,
artist: { id: 1, name: 'Sabu', slug: 'sabu' },
source: { id: 1, platform: 'subscribestar' },
thumbnails: [], thumbnails_more: 0, attachments: [],
}
describe('PostCard', () => {
it('renders the HTML-stripped title and the artist', () => {
const pinia = freshPinia()
const now = new Date().toISOString()
const post = {
id: 1, external_post_id: 'P1',
post_title: '<strong>Hello World</strong>',
post_url: 'https://x/1', post_date: now, downloaded_at: now,
description_plain: 'a description',
artist: { id: 1, name: 'Sabu', slug: 'sabu' },
source: { id: 1, platform: 'subscribestar' },
thumbnails: [], thumbnails_more: 0, attachments: [],
}
const w = mountComponent(PostCard, { props: { post }, pinia })
const post = { ...BASE, description_plain: 'a description' }
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
const t = w.text()
expect(t).toContain('Hello World') // plain title
expect(t).not.toContain('<strong>') // tags stripped
expect(t).toContain('Sabu') // artist (RouterLink slot)
})
it('shows a "Show more" toggle only when the description is truncated', () => {
const truncated = mountComponent(PostCard, {
props: { post: { ...BASE, description_plain: 'lots of text…', description_truncated: true } },
pinia: freshPinia(),
})
expect(truncated.text()).toContain('Show more')
const full = mountComponent(PostCard, {
props: { post: { ...BASE, description_plain: 'short', description_truncated: false } },
pinia: freshPinia(),
})
expect(full.text()).not.toContain('Show more')
})
it('clicking an image opens the post-scoped modal (never expands the card)', async () => {
const pinia = freshPinia()
const modal = useModalStore()
const openSpy = vi.spyOn(modal, 'open').mockResolvedValue()
const post = {
...BASE,
description_plain: 'd',
thumbnails: [
{ image_id: 10, thumbnail_url: '/a' },
{ image_id: 11, thumbnail_url: '/b' },
],
thumbnails_more: 0,
}
const w = mountComponent(PostCard, { props: { post }, pinia })
await w.find('.fc-post-card__hero').trigger('click')
await flushPromises()
expect(openSpy).toHaveBeenCalledWith(10, { postImageIds: [10, 11] })
})
})
+27
View File
@@ -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',
+4 -4
View File
@@ -46,8 +46,8 @@ describe('showcase store: buffered cascade', () => {
stubShowcase()
const s = useShowcaseStore()
const p = s.loadInitial()
// Past INITIAL_COUNT (60) × CADENCE (80ms) plus fetch microtasks.
await vi.advanceTimersByTimeAsync(6000)
// Past INITIAL_COUNT (60) × CADENCE (160ms = 9.6s) plus fetch microtasks.
await vi.advanceTimersByTimeAsync(12000)
await p
expect(s.images.length).toBe(60)
const ids = s.images.map((i) => i.id)
@@ -65,7 +65,7 @@ describe('showcase store: buffered cascade', () => {
// the cadence — a 1s window must not dump the whole buffer at once.
expect(early).toBeGreaterThan(0)
expect(early).toBeLessThan(60)
await vi.advanceTimersByTimeAsync(6000)
await vi.advanceTimersByTimeAsync(12000)
await p
expect(s.images.length).toBe(60)
})
@@ -80,7 +80,7 @@ describe('showcase store: buffered cascade', () => {
await vi.advanceTimersByTimeAsync(1000)
expect(s.images.length).toBe(0) // gated on the un-decoded first image
release()
await vi.advanceTimersByTimeAsync(6000)
await vi.advanceTimersByTimeAsync(12000)
await p
expect(s.images.length).toBe(60) // cascade proceeds once it decodes
})
+23
View File
@@ -435,3 +435,26 @@ async def test_trigger_vacuum_queues_the_task(client, monkeypatch):
resp = await client.post("/api/admin/maintenance/vacuum")
assert resp.status_code == 202
assert calls == [1]
# --- Tier-A: POST /tags/reset-content -------------------------------
@pytest.mark.asyncio
async def test_reset_content_tagging_dry_run_returns_counts(client, db):
db.add_all([
Tag(name="solo", kind=TagKind.general),
Tag(name="naruto", kind=TagKind.character),
Tag(name="Naruto", kind=TagKind.fandom),
Tag(name="my-series", kind=TagKind.series),
])
await db.commit()
resp = await client.post(
"/api/admin/tags/reset-content", json={"dry_run": True}
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["count"] == 2
assert body["by_kind"] == {"general": 1, "character": 1}
# dry-run leaves the rows in place — fandom + series untouched too.
assert "deleted" not in body
+21
View File
@@ -155,6 +155,27 @@ async def test_audit_history_returns_recent_runs(client, db):
assert len(body["runs"]) >= 3
@pytest.mark.asyncio
async def test_audit_history_filters_by_rule(client, db):
db.add(LibraryAuditRun(
rule="transparency", params={"threshold": 0.9},
status="applied", matched_ids=[], finished_at=datetime.now(UTC),
))
db.add(LibraryAuditRun(
rule="single_color", params={"threshold": 0.95, "tolerance": 30},
status="ready", matched_count=2, matched_ids=[1, 2],
finished_at=datetime.now(UTC),
))
await db.commit()
# ?rule=&limit=1 → just THIS rule's latest run (the card-reconnect query).
resp = await client.get("/api/cleanup/audit?rule=single_color&limit=1")
assert resp.status_code == 200
runs = (await resp.get_json())["runs"]
assert len(runs) == 1
assert runs[0]["rule"] == "single_color"
assert runs[0]["matched_count"] == 2
@pytest.mark.asyncio
async def test_audit_apply_with_token_deletes(client, db, tmp_path):
rec = await _seed_image(db, tmp_path, w=100, h=100, name="apply.png")
+60
View File
@@ -9,6 +9,7 @@ import pytest
from sqlalchemy import func, select
from backend.app.models import Artist, ImageRecord, Tag, TagKind
from backend.app.models.series_page import SeriesPage
from backend.app.models.tag import image_tag
from backend.app.services import cleanup_service
@@ -333,3 +334,62 @@ def test_prune_unused_tags_commit_deletes_them(db_sync, tmp_path):
surviving_names = db_sync.execute(select(Tag.name)).scalars().all()
assert "kept" in surviving_names
assert "bye" not in surviving_names
# --- reset_content_tagging ------------------------------------------
def test_reset_content_tagging_dry_run_counts_without_deleting(db_sync, tmp_path):
a = _make_artist(db_sync, slug="rc")
img = _make_image(
db_sync, artist=a, path=str(tmp_path / "r.jpg"), sha256="1" * 64,
)
g = _make_tag(db_sync, name="solo", kind=TagKind.general)
c = _make_tag(db_sync, name="naruto", kind=TagKind.character)
_make_tag(db_sync, name="Naruto", kind=TagKind.fandom)
_make_tag(db_sync, name="my-series", kind=TagKind.series)
db_sync.execute(image_tag.insert().values([
{"image_record_id": img.id, "tag_id": g.id},
{"image_record_id": img.id, "tag_id": c.id},
]))
db_sync.commit()
result = cleanup_service.reset_content_tagging(db_sync, dry_run=True)
assert result["count"] == 2
assert result["by_kind"] == {"general": 1, "character": 1}
assert result["applications"] == 2
# Nothing deleted — all 4 tags still present.
assert db_sync.execute(select(func.count(Tag.id))).scalar_one() == 4
def test_reset_content_tagging_deletes_content_keeps_fandom_series(db_sync, tmp_path):
a = _make_artist(db_sync, slug="rc2")
img = _make_image(
db_sync, artist=a, path=str(tmp_path / "r2.jpg"), sha256="2" * 64,
)
g = _make_tag(db_sync, name="solo", kind=TagKind.general)
c = _make_tag(db_sync, name="naruto", kind=TagKind.character)
_make_tag(db_sync, name="Naruto", kind=TagKind.fandom)
s = _make_tag(db_sync, name="my-series", kind=TagKind.series)
db_sync.execute(image_tag.insert().values([
{"image_record_id": img.id, "tag_id": g.id},
{"image_record_id": img.id, "tag_id": c.id},
{"image_record_id": img.id, "tag_id": s.id}, # series membership
]))
db_sync.add(SeriesPage(series_tag_id=s.id, image_id=img.id, page_number=1))
db_sync.commit()
result = cleanup_service.reset_content_tagging(db_sync, dry_run=False)
assert result["deleted"] == 2
# general + character gone; fandom + series kept.
kinds = db_sync.execute(select(Tag.kind)).scalars().all()
kind_vals = {k.value if hasattr(k, "value") else str(k) for k in kinds}
assert kind_vals == {"fandom", "series"}
# series_page ordering survived.
assert db_sync.execute(
select(func.count()).select_from(SeriesPage)
).scalar_one() == 1
# Only the series image_tag association survived (content ones cascaded).
remaining = db_sync.execute(select(image_tag.c.tag_id)).scalars().all()
assert remaining == [s.id]
+134
View File
@@ -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