Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a8f7cd8b6 | ||
|
|
86efbf7f2c | ||
|
|
3a0cca5aca | ||
|
|
83f8af8090 | ||
|
|
a5b3702863 | ||
|
|
9a2617c1a2 | ||
|
|
509a7958cf | ||
|
|
81688815a0 | ||
|
|
5a6a95682d | ||
|
|
91b0145bc8 | ||
|
|
26e47a86cb | ||
|
|
773128c3bf | ||
|
|
928e3037f0 | ||
|
|
ce7b154ae9 | ||
|
|
b08b12eb8f | ||
|
|
9430a9d9c3 | ||
|
|
4fd6d4cc29 | ||
|
|
304e8aa878 | ||
|
|
0497394710 | ||
|
|
21a73cd1dc | ||
|
|
79cd1234e2 |
@@ -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")
|
||||||
@@ -224,6 +224,27 @@ async def tags_purge_legacy():
|
|||||||
return jsonify(result)
|
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"])
|
@admin_bp.route("/maintenance/db-stats", methods=["GET"])
|
||||||
async def db_stats():
|
async def db_stats():
|
||||||
"""Per-table bloat readout (pg_stat_user_tables) for the high-churn tables
|
"""Per-table bloat readout (pg_stat_user_tables) for the high-churn tables
|
||||||
|
|||||||
@@ -154,12 +154,15 @@ async def audit_history():
|
|||||||
limit = min(int(request.args.get("limit", "20")), 100)
|
limit = min(int(request.args.get("limit", "20")), 100)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return _bad("invalid_limit")
|
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:
|
async with get_session() as session:
|
||||||
rows = (await session.execute(
|
stmt = select(LibraryAuditRun).order_by(LibraryAuditRun.id.desc())
|
||||||
select(LibraryAuditRun)
|
if rule is not None:
|
||||||
.order_by(LibraryAuditRun.id.desc())
|
stmt = stmt.where(LibraryAuditRun.rule == rule)
|
||||||
.limit(limit)
|
rows = (await session.execute(stmt.limit(limit))).scalars().all()
|
||||||
)).scalars().all()
|
|
||||||
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
|
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+46
-14
@@ -10,6 +10,21 @@ from ..services.gallery_service import GalleryService
|
|||||||
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
|
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):
|
def _parse_date(raw):
|
||||||
"""Parse a YYYY-MM-DD query value to a UTC midnight datetime, or None.
|
"""Parse a YYYY-MM-DD query value to a UTC midnight datetime, or None.
|
||||||
Raises ValueError (→ 400) on a malformed value."""
|
Raises ValueError (→ 400) on a malformed value."""
|
||||||
@@ -76,20 +91,7 @@ async def scroll():
|
|||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"images": [
|
"images": [_image_json(i) for i in page.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
|
|
||||||
],
|
|
||||||
"next_cursor": page.next_cursor,
|
"next_cursor": page.next_cursor,
|
||||||
"date_groups": [
|
"date_groups": [
|
||||||
{"year": y, "month": m, "image_ids": ids} for y, m, ids in page.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"])
|
@gallery_bp.route("/timeline", methods=["GET"])
|
||||||
async def timeline():
|
async def timeline():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -455,6 +455,62 @@ def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict:
|
|||||||
return result
|
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.
|
# FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -245,6 +245,27 @@ def _provenance_clause(post_id, artist_id):
|
|||||||
return None
|
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]:
|
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
|
||||||
"""Map image_id -> {"name","slug"} via the canonical
|
"""Map image_id -> {"name","slug"} via the canonical
|
||||||
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
||||||
@@ -324,22 +345,7 @@ class GalleryService:
|
|||||||
artists = await _artists_for(
|
artists = await _artists_for(
|
||||||
self.session, [r[0].id for r in rows]
|
self.session, [r[0].id for r in rows]
|
||||||
)
|
)
|
||||||
images = [
|
images = _gallery_images(rows, artists)
|
||||||
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
|
|
||||||
]
|
|
||||||
return GalleryPage(
|
return GalleryPage(
|
||||||
images=images,
|
images=images,
|
||||||
next_cursor=next_cursor,
|
next_cursor=next_cursor,
|
||||||
@@ -505,6 +511,49 @@ class GalleryService:
|
|||||||
date_min=dmin, date_max=dmax,
|
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:
|
async def get_image_with_tags(self, image_id: int) -> dict | None:
|
||||||
record = await self.session.get(ImageRecord, image_id)
|
record = await self.session.get(ImageRecord, image_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
@@ -542,6 +591,9 @@ class GalleryService:
|
|||||||
"height": record.height,
|
"height": record.height,
|
||||||
"size_bytes": record.size_bytes,
|
"size_bytes": record.size_bytes,
|
||||||
"integrity_status": record.integrity_status,
|
"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(),
|
"created_at": record.created_at.isoformat(),
|
||||||
"posted_at": posted_at.isoformat() if posted_at else None,
|
"posted_at": posted_at.isoformat() if posted_at else None,
|
||||||
"thumbnail_url": thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
"thumbnail_url": thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
<PipelineStatusChip />
|
<PipelineStatusChip />
|
||||||
</div>
|
</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">
|
<nav class="fc-links">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="r in navRoutes"
|
v-for="r in navRoutes"
|
||||||
@@ -20,9 +22,31 @@
|
|||||||
>{{ r.meta.title }}</RouterLink>
|
>{{ r.meta.title }}</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Per-view contextual actions teleport here (Showcase: Shuffle,
|
<div class="fc-nav-right">
|
||||||
Gallery: Select). TopNav owns the slot, not its contents. -->
|
<!-- Per-view contextual actions teleport here (Showcase: Shuffle,
|
||||||
<div id="fc-nav-actions" class="fc-nav-actions" />
|
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>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -137,7 +161,7 @@ const health = computed(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.fc-nav-actions {
|
.fc-nav-right {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -145,4 +169,22 @@ const health = computed(() => {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0.5rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -112,6 +112,16 @@ onMounted(async () => {
|
|||||||
await store.loadDefaults()
|
await store.loadDefaults()
|
||||||
threshold.value = store.defaults.single_color_threshold
|
threshold.value = store.defaults.single_color_threshold
|
||||||
tolerance.value = store.defaults.single_color_tolerance
|
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())
|
onUnmounted(() => stopPoll())
|
||||||
|
|||||||
@@ -97,6 +97,16 @@ let pollTimer = null
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await store.loadDefaults()
|
await store.loadDefaults()
|
||||||
threshold.value = store.defaults.transparency_threshold
|
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())
|
onUnmounted(() => stopPoll())
|
||||||
|
|||||||
@@ -186,7 +186,9 @@ async function onDeleteConfirm(token) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-bulk-panel {
|
.fc-bulk-panel {
|
||||||
position: fixed; top: 0; right: 0;
|
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));
|
background: rgb(var(--v-theme-surface));
|
||||||
border-left: 1px solid rgb(var(--v-theme-surface-light));
|
border-left: 1px solid rgb(var(--v-theme-surface-light));
|
||||||
box-shadow: -2px 0 16px rgba(0, 0, 0, 0.4);
|
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__empty { color: rgb(var(--v-theme-on-surface-variant)); font-size: 0.8rem; }
|
||||||
.fc-facets__date { max-width: 160px; }
|
.fc-facets__date { max-width: 160px; }
|
||||||
.fc-facets__dash { color: rgb(var(--v-theme-on-surface-variant)); }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
prepend-icon="mdi-account"
|
prepend-icon="mdi-account"
|
||||||
@click:close="clearArtist"
|
@click:close="clearArtist"
|
||||||
>{{ store.artistLabel || `Artist #${store.filter.artist_id}` }}</v-chip>
|
>{{ 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>
|
</div>
|
||||||
|
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
@@ -52,7 +58,9 @@
|
|||||||
<v-btn value="video" size="small">Videos</v-btn>
|
<v-btn value="video" size="small">Videos</v-btn>
|
||||||
</v-btn-toggle>
|
</v-btn-toggle>
|
||||||
|
|
||||||
|
<!-- Sort is meaningless in similar-mode (results are distance-ranked). -->
|
||||||
<v-select
|
<v-select
|
||||||
|
v-if="!store.filter.similar_to"
|
||||||
:model-value="store.filter.sort"
|
:model-value="store.filter.sort"
|
||||||
:items="SORTS"
|
:items="SORTS"
|
||||||
density="compact" hide-details variant="outlined"
|
density="compact" hide-details variant="outlined"
|
||||||
@@ -114,6 +122,7 @@ const hasActiveFilters = computed(() =>
|
|||||||
store.filter.artist_id != null ||
|
store.filter.artist_id != null ||
|
||||||
store.filter.media_type != null ||
|
store.filter.media_type != null ||
|
||||||
store.filter.sort !== 'newest' ||
|
store.filter.sort !== 'newest' ||
|
||||||
|
store.filter.similar_to != null ||
|
||||||
hasRefineFilters.value
|
hasRefineFilters.value
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -190,6 +199,7 @@ function clearArtist() {
|
|||||||
store.noteArtistLabel(null)
|
store.noteArtistLabel(null)
|
||||||
pushFilter((n) => { n.artist_id = 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 setMedia(m) { pushFilter((n) => { n.media_type = m }) }
|
||||||
function setSort(s) { pushFilter((n) => { n.sort = s }) }
|
function setSort(s) { pushFilter((n) => { n.sort = s }) }
|
||||||
function clearAll() { router.push({ name: 'gallery', query: {} }) }
|
function clearAll() { router.push({ name: 'gallery', query: {} }) }
|
||||||
@@ -207,7 +217,7 @@ function pushFilter(mutate) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* The whole chrome (bar row + expandable refine panel) is one sticky,
|
/* 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 {
|
.fc-filterbar-wrap {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 64px;
|
top: 64px;
|
||||||
@@ -217,9 +227,18 @@ function pushFilter(mutate) {
|
|||||||
and a gap shows through when scrolled to the top. */
|
and a gap shows through when scrolled to the top. */
|
||||||
margin-top: -8px;
|
margin-top: -8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
/* Same hazey obsidian (#14171A = 20,23,26) + blur as the TopNav so the two
|
/* EXACT same gradiated obsidian (#14171A = 20,23,26) frost as the TopNav so
|
||||||
read as one piece of chrome; content scrolls under both. */
|
the two read as one continuous piece of chrome — images scroll visibly
|
||||||
background: rgba(20, 23, 26, 0.55);
|
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);
|
backdrop-filter: blur(2px);
|
||||||
-webkit-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__search { max-width: 320px; min-width: 200px; }
|
||||||
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
.fc-filterbar__sort { max-width: 150px; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -15,6 +15,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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">
|
<div v-if="store.loading" class="fc-gallery-grid__sentinel">
|
||||||
<v-progress-circular indeterminate color="accent" size="28" />
|
<v-progress-circular indeterminate color="accent" size="28" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<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-spacer />
|
||||||
<v-btn @click="$emit('cancel')">Cancel</v-btn>
|
<v-btn @click="$emit('cancel')">Cancel</v-btn>
|
||||||
<v-btn :disabled="!selectedId" color="primary" rounded="pill" @click="onConfirm">Use this fandom</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)
|
const f = store.fandomCache.find(x => x.id === selectedId.value)
|
||||||
if (f) emit('confirm', f)
|
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>
|
</script>
|
||||||
|
|||||||
@@ -51,6 +51,9 @@
|
|||||||
<aside v-if="modal.current" class="fc-viewer__side">
|
<aside v-if="modal.current" class="fc-viewer__side">
|
||||||
<ProvenancePanel />
|
<ProvenancePanel />
|
||||||
<TagPanel />
|
<TagPanel />
|
||||||
|
<!-- Non-blocking: fetches its own similar set after the modal is up;
|
||||||
|
collapses silently if empty/slow/failed (see RelatedStrip). -->
|
||||||
|
<RelatedStrip />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,6 +68,7 @@ import ImageCanvas from './ImageCanvas.vue'
|
|||||||
import VideoCanvas from './VideoCanvas.vue'
|
import VideoCanvas from './VideoCanvas.vue'
|
||||||
import TagPanel from './TagPanel.vue'
|
import TagPanel from './TagPanel.vue'
|
||||||
import ProvenancePanel from './ProvenancePanel.vue'
|
import ProvenancePanel from './ProvenancePanel.vue'
|
||||||
|
import RelatedStrip from './RelatedStrip.vue'
|
||||||
|
|
||||||
const emit = defineEmits(['close'])
|
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
|
Accept
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<!-- Operator-flagged 2026-06-02: the kebab menu wasn't opening.
|
<!-- Operator-flagged 2026-06-04: the kebab still wasn't opening. The
|
||||||
Wrapping in a <span @click.stop> matches the TagPanel chip
|
prior `#activator` + `v-bind="props"` path never toggled the menu
|
||||||
fix — even though there's no parent click capture here today,
|
inside this teleported modal, while v-model-driven overlays (the
|
||||||
the wrap is harmless and keeps both kebabs on the same
|
dialogs in this modal) work fine. So drive the menu explicitly:
|
||||||
pattern. Click bubbles from the v-btn → opens menu via
|
the button toggles `menuOpen` with @click.stop (also shields any
|
||||||
activator props → bubble continues to span → stopPropagation
|
parent), and `activator="parent"` anchors the menu for positioning
|
||||||
halts it. -->
|
only — `:open-on-click="false"` keeps Vuetify's activator-click out
|
||||||
<span class="fc-suggestion__menu-wrap" @click.stop>
|
of it, so there's a single, reliable opener. -->
|
||||||
<v-menu>
|
<span class="fc-suggestion__menu-wrap">
|
||||||
<template #activator="{ props }">
|
<v-btn
|
||||||
<v-btn
|
class="fc-suggestion__menu"
|
||||||
class="fc-suggestion__menu"
|
icon="mdi-dots-vertical" size="small"
|
||||||
icon="mdi-dots-vertical" size="small"
|
variant="outlined" density="compact"
|
||||||
variant="outlined" density="compact"
|
:aria-label="`More actions for ${suggestion.display_name}`"
|
||||||
:aria-label="`More actions for ${suggestion.display_name}`"
|
@click.stop="menuOpen = !menuOpen"
|
||||||
v-bind="props"
|
/>
|
||||||
/>
|
<v-menu
|
||||||
</template>
|
v-model="menuOpen" activator="parent" :open-on-click="false"
|
||||||
|
>
|
||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
<v-list-item @click="$emit('alias', suggestion)">
|
<v-list-item @click="$emit('alias', suggestion)">
|
||||||
<v-list-item-title>Treat as alias for…</v-list-item-title>
|
<v-list-item-title>Treat as alias for…</v-list-item-title>
|
||||||
@@ -51,11 +52,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({ suggestion: { type: Object, required: true } })
|
const props = defineProps({ suggestion: { type: Object, required: true } })
|
||||||
defineEmits(['accept', 'alias', 'dismiss'])
|
defineEmits(['accept', 'alias', 'dismiss'])
|
||||||
|
|
||||||
|
const menuOpen = ref(false)
|
||||||
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -147,10 +147,14 @@ function onCreate () {
|
|||||||
reset()
|
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) {
|
function onFandomChosen (fandom) {
|
||||||
fandomDialog.value = false
|
fandomDialog.value = false
|
||||||
emit('pick-new', {
|
emit('pick-new', {
|
||||||
name: pendingNewName, kind: 'character', fandom_id: fandom.id,
|
name: pendingNewName, kind: 'character', fandom_id: fandom ? fandom.id : null,
|
||||||
})
|
})
|
||||||
pendingNewName = null
|
pendingNewName = null
|
||||||
reset()
|
reset()
|
||||||
|
|||||||
@@ -10,20 +10,24 @@
|
|||||||
>
|
>
|
||||||
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
|
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
|
||||||
{{ tag.name }}<span v-if="tag.fandom_id">→</span>
|
{{ tag.name }}<span v-if="tag.fandom_id">→</span>
|
||||||
<!-- Operator-flagged 2026-06-02: the previous activator had
|
<!-- Operator-flagged 2026-06-04: the `#activator` + `v-bind` menu
|
||||||
`@click.stop` directly on the v-icon, which silently
|
never opened inside this teleported modal. Drive it explicitly
|
||||||
overrode Vuetify's onClick from `v-bind="mp"` — the menu
|
instead (same mechanism as the dialogs below, which work): the
|
||||||
never opened. Now the v-icon receives the activator
|
icon toggles `openTagId` with @click.stop (shielding the chip's
|
||||||
onClick cleanly, and the wrapping span absorbs the
|
close button), and `activator="parent"` + `:open-on-click=false`
|
||||||
bubbled click so the chip's close button isn't tripped. -->
|
anchors the menu for positioning only. One tag's menu open at a
|
||||||
<span class="kebab-wrap" @click.stop>
|
time, so a single id is enough. -->
|
||||||
<v-menu>
|
<span class="kebab-wrap">
|
||||||
<template #activator="{ props: mp }">
|
<v-icon
|
||||||
<v-icon
|
size="x-small" class="ml-1 kebab-icon"
|
||||||
v-bind="mp" size="x-small" class="ml-1"
|
icon="mdi-dots-vertical"
|
||||||
icon="mdi-dots-vertical"
|
@click.stop="openTagId = openTagId === tag.id ? null : tag.id"
|
||||||
/>
|
/>
|
||||||
</template>
|
<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 density="compact">
|
||||||
<v-list-item @click="openRename(tag)">
|
<v-list-item @click="openRename(tag)">
|
||||||
<v-list-item-title>Rename…</v-list-item-title>
|
<v-list-item-title>Rename…</v-list-item-title>
|
||||||
@@ -84,6 +88,9 @@ import FandomSetDialog from './FandomSetDialog.vue'
|
|||||||
const modal = useModalStore()
|
const modal = useModalStore()
|
||||||
const store = useTagStore()
|
const store = useTagStore()
|
||||||
const errorMsg = ref(null)
|
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 = {
|
const KIND_ICONS = {
|
||||||
general: 'mdi-tag', character: 'mdi-account-circle',
|
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; }
|
.fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
.kebab-wrap { display: inline-flex; align-items: center; }
|
.kebab-wrap { display: inline-flex; align-items: center; }
|
||||||
|
.kebab-icon { cursor: pointer; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,29 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-card
|
<v-card class="fc-post-card" variant="outlined">
|
||||||
:class="['fc-post-card', expanded && 'fc-post-card--expanded']"
|
|
||||||
variant="outlined"
|
|
||||||
:tabindex="expanded ? -1 : 0"
|
|
||||||
@click="onCardClick"
|
|
||||||
@keydown.enter="onCardClick"
|
|
||||||
>
|
|
||||||
<div class="fc-post-card__head">
|
<div class="fc-post-card__head">
|
||||||
<!-- Posts with no live subscription have source=null (alembic
|
<!-- Posts with no live subscription have source=null (alembic 0030);
|
||||||
0030); show a "filesystem import" affordance instead of a
|
show a "filesystem import" affordance instead of a platform chip. -->
|
||||||
platform chip. -->
|
|
||||||
<v-chip size="x-small" variant="tonal">
|
<v-chip size="x-small" variant="tonal">
|
||||||
{{ post.source?.platform ?? 'filesystem import' }}
|
{{ post.source?.platform ?? 'filesystem import' }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
|
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
|
||||||
class="fc-post-card__artist"
|
class="fc-post-card__artist"
|
||||||
@click.stop
|
|
||||||
>{{ post.artist.name }}</RouterLink>
|
>{{ post.artist.name }}</RouterLink>
|
||||||
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
|
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
|
||||||
<span v-if="expanded && images.length" class="fc-post-card__meta">
|
<span v-if="totalImages" class="fc-post-card__meta">
|
||||||
· {{ images.length }} image{{ images.length === 1 ? '' : 's' }}
|
· {{ totalImages }} image{{ totalImages === 1 ? '' : 's' }}
|
||||||
</span>
|
|
||||||
<span v-if="expanded && attachments.length" class="fc-post-card__meta">
|
|
||||||
· {{ attachments.length }} attachment{{ attachments.length === 1 ? '' : 's' }}
|
|
||||||
</span>
|
</span>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -31,140 +20,108 @@
|
|||||||
:href="post.post_url" target="_blank" rel="noopener"
|
:href="post.post_url" target="_blank" rel="noopener"
|
||||||
icon="mdi-open-in-new" size="x-small" variant="text"
|
icon="mdi-open-in-new" size="x-small" variant="text"
|
||||||
:aria-label="`open original post on ${post.source?.platform ?? 'web'}`"
|
: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>
|
</div>
|
||||||
|
|
||||||
<!-- Compact body: collapsed card. Hero + thumb rail + truncated text. -->
|
<div class="fc-post-card__body">
|
||||||
<div v-if="!expanded" class="fc-post-card__body">
|
|
||||||
<div class="fc-post-card__media">
|
<div class="fc-post-card__media">
|
||||||
<template v-if="images.length">
|
<template v-if="images.length">
|
||||||
<div class="fc-post-card__hero">
|
<!-- Images open the post-scoped image modal (look bigger + arrow
|
||||||
<img :src="hero.thumbnail_url" :alt="`hero thumbnail`" loading="lazy" />
|
through ALL the post's images) — the card never expands. -->
|
||||||
</div>
|
<button
|
||||||
<div v-if="rail.length" class="fc-post-card__rail">
|
type="button" class="fc-post-card__hero"
|
||||||
<div v-for="t in rail" :key="t.image_id" class="fc-post-card__rail-cell">
|
aria-label="Open images" @click="openModal(hero.image_id)"
|
||||||
<img :src="t.thumbnail_url" :alt="`thumbnail`" loading="lazy" />
|
>
|
||||||
</div>
|
<img :src="hero.thumbnail_url" alt="hero thumbnail" loading="lazy" />
|
||||||
<div v-if="moreCount > 0" class="fc-post-card__rail-more">
|
</button>
|
||||||
+{{ moreCount }}
|
<div v-if="rail.length || moreCount" class="fc-post-card__rail">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<PostEmptyThumbs v-else />
|
<PostEmptyThumbs v-else />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fc-post-card__text">
|
<div class="fc-post-card__text">
|
||||||
<h3 v-if="plainTitle" class="fc-post-card__title">
|
<h3 v-if="plainTitle" class="fc-post-card__title">{{ plainTitle }}</h3>
|
||||||
{{ plainTitle }}
|
|
||||||
</h3>
|
|
||||||
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
|
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
|
||||||
Post {{ post.external_post_id }}
|
Post {{ post.external_post_id }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p v-if="post.description_plain" class="fc-post-card__desc">
|
<p
|
||||||
{{ post.description_plain }}
|
v-if="hasDescription" ref="descEl"
|
||||||
</p>
|
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">
|
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
|
||||||
(no description)
|
(no description)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="post.attachments?.length" class="fc-post-card__atts">
|
<!-- The ONLY in-place expansion: the post text, and only when it's
|
||||||
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
|
actually truncated (server flag or a CSS-clamp overflow). -->
|
||||||
{{ post.attachments.length }} attachment{{ post.attachments.length === 1 ? '' : 's' }}
|
<button
|
||||||
</div>
|
v-if="canExpand" type="button" class="fc-post-card__more"
|
||||||
</div>
|
@click="toggleDesc"
|
||||||
</div>
|
>{{ descExpanded ? 'Show less' : 'Show more' }}</button>
|
||||||
|
|
||||||
<!-- Expanded body: title, full mosaic, full sanitized HTML description,
|
<div v-if="attachments.length" class="fc-post-card__atts">
|
||||||
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">
|
|
||||||
<a
|
<a
|
||||||
v-for="att in attachments" :key="att.id"
|
v-for="att in attachments" :key="att.id"
|
||||||
:href="att.download_url" download
|
:href="att.download_url" download class="fc-post-card__att"
|
||||||
class="fc-post-card__att"
|
|
||||||
@click.stop
|
|
||||||
>
|
>
|
||||||
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
|
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
|
||||||
<span>{{ att.original_filename }}</span>
|
<span>{{ att.original_filename }}</span>
|
||||||
<span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span>
|
<span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import { useModalStore } from '../../stores/modal.js'
|
||||||
import { usePostsStore } from '../../stores/posts.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 PostEmptyThumbs from './PostEmptyThumbs.vue'
|
||||||
import PostImageGrid from './PostImageGrid.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
post: { type: Object, required: true },
|
post: { type: Object, required: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const postsStore = usePostsStore()
|
const postsStore = usePostsStore()
|
||||||
|
const modal = useModalStore()
|
||||||
|
|
||||||
// Per-card expand state. No global modal — each PostCard owns its own
|
// Full detail (uncapped thumbnails + full description), fetched lazily — only
|
||||||
// view-mode and lazy-loaded detail.
|
// when opening the modal for a post with >6 images, or expanding a
|
||||||
const expanded = ref(false)
|
// server-truncated description.
|
||||||
const detail = ref(null)
|
const detail = ref(null)
|
||||||
const detailLoaded = ref(false)
|
|
||||||
const detailError = ref(null)
|
|
||||||
|
|
||||||
// When expanded + detail loaded, prefer the uncapped detail thumbnails +
|
const attachments = computed(() => props.post.attachments || [])
|
||||||
// full description. Falls back to feed shape if detail fetch is in flight
|
const images = computed(() => props.post.thumbnails || [])
|
||||||
// or failed.
|
const totalImages = computed(() => images.value.length + (props.post.thumbnails_more || 0))
|
||||||
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 plainTitle = computed(() => toPlainText(props.post.post_title))
|
const plainTitle = computed(() => toPlainText(props.post.post_title))
|
||||||
|
|
||||||
// Compact-view hero+rail derived from the feed-shape (capped 6).
|
const hero = computed(() => images.value[0])
|
||||||
const hero = computed(() => props.post.thumbnails?.[0])
|
const rail = computed(() => images.value.slice(1, 4))
|
||||||
const rail = computed(() => (props.post.thumbnails || []).slice(1, 4))
|
const visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
|
||||||
const moreCount = computed(() => {
|
const moreCount = computed(() => {
|
||||||
const more = props.post.thumbnails_more || 0
|
const more = props.post.thumbnails_more || 0
|
||||||
const railLen = rail.value.length
|
const extraShown = Math.max(0, images.value.length - visibleCount.value)
|
||||||
const extraShown = Math.max(0, (props.post.thumbnails?.length || 0) - 1 - railLen)
|
|
||||||
return more + extraShown
|
return more + extraShown
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -180,49 +137,77 @@ const relativeDate = computed(() => {
|
|||||||
return new Date(sortDateIso.value).toLocaleDateString()
|
return new Date(sortDateIso.value).toLocaleDateString()
|
||||||
})
|
})
|
||||||
|
|
||||||
const descriptionHtml = computed(() => {
|
// --- images → post-scoped modal ---------------------------------------
|
||||||
// Detail endpoint returns description_full as plain text (the service
|
async function fullImageIds () {
|
||||||
// uses html_to_plain on the stored description). Render plain text in
|
// Feed caps thumbnails at 6; load detail for the complete id list only when
|
||||||
// <p> wrappers; sanitize defensively in case the backend ever returns
|
// there are more, so the modal can arrow through ALL of the post's images.
|
||||||
// raw HTML.
|
if ((props.post.thumbnails_more || 0) === 0) {
|
||||||
const raw = merged.value.description_full || merged.value.description_plain
|
return images.value.map((t) => t.image_id)
|
||||||
if (!raw) return ''
|
|
||||||
if (/[<>]/.test(raw)) return sanitizeHtml(raw)
|
|
||||||
const esc = raw
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
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.
|
|
||||||
}
|
}
|
||||||
|
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 () {
|
async function openModal (imageId) {
|
||||||
expanded.value = !expanded.value
|
modal.open(imageId, { postImageIds: await fullImageIds() })
|
||||||
if (expanded.value) loadDetailIfNeeded()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCardClick (e) {
|
async function openModalAtMore () {
|
||||||
// Inner interactive elements use @click.stop so they never reach here.
|
const ids = await fullImageIds()
|
||||||
// Whole-card click expands a collapsed card; collapsing is chevron-only
|
const first = ids[visibleCount.value] ?? ids[0]
|
||||||
// so a mosaic-image click on an expanded card can never accidentally
|
if (first != null) modal.open(first, { postImageIds: ids })
|
||||||
// collapse the surrounding card.
|
}
|
||||||
if (expanded.value) return
|
|
||||||
expanded.value = true
|
// --- description "Show more" (text-only, in place, only when truncated) ----
|
||||||
loadDetailIfNeeded()
|
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) {
|
function formatBytes (n) {
|
||||||
@@ -239,20 +224,6 @@ function formatBytes (n) {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
container-type: inline-size;
|
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 {
|
.fc-post-card__head {
|
||||||
@@ -273,7 +244,6 @@ function formatBytes (n) {
|
|||||||
.fc-post-card__date,
|
.fc-post-card__date,
|
||||||
.fc-post-card__meta { white-space: nowrap; }
|
.fc-post-card__meta { white-space: nowrap; }
|
||||||
|
|
||||||
/* ---- COMPACT BODY ---- */
|
|
||||||
.fc-post-card__body {
|
.fc-post-card__body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -288,6 +258,13 @@ function formatBytes (n) {
|
|||||||
.fc-post-card__text { flex: 1 1 0; min-width: 0; }
|
.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 {
|
.fc-post-card__hero {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 10;
|
aspect-ratio: 16 / 10;
|
||||||
@@ -297,10 +274,13 @@ function formatBytes (n) {
|
|||||||
.fc-post-card__hero img {
|
.fc-post-card__hero img {
|
||||||
width: 100%; height: 100%;
|
width: 100%; height: 100%;
|
||||||
object-fit: cover; display: block;
|
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 {
|
.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 {
|
.fc-post-card__rail-cell {
|
||||||
width: 80px; height: 80px;
|
width: 80px; height: 80px;
|
||||||
@@ -318,6 +298,10 @@ function formatBytes (n) {
|
|||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
font-size: 0.85rem;
|
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 {
|
.fc-post-card__title {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
@@ -346,67 +330,36 @@ function formatBytes (n) {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: rgb(var(--v-theme-on-surface));
|
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;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
@container (min-width: 800px) {
|
||||||
|
.fc-post-card__desc--clamped { -webkit-line-clamp: 5; }
|
||||||
|
}
|
||||||
.fc-post-card__desc--missing {
|
.fc-post-card__desc--missing {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
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 {
|
.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;
|
display: flex; flex-wrap: wrap; gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
.fc-post-card__att {
|
.fc-post-card__att {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -423,5 +376,6 @@ function formatBytes (n) {
|
|||||||
color: rgb(var(--v-theme-accent));
|
color: rgb(var(--v-theme-accent));
|
||||||
border-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)); }
|
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
</style>
|
</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
|
clearable
|
||||||
no-filter
|
no-filter
|
||||||
return-object
|
return-object
|
||||||
style="min-width: 240px"
|
class="fc-posts-filters__artist"
|
||||||
@update:search="onArtistSearch"
|
@update:search="onArtistSearch"
|
||||||
@update:model-value="onArtistPicked"
|
@update:model-value="onArtistPicked"
|
||||||
/>
|
/>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
density="compact"
|
density="compact"
|
||||||
hide-details
|
hide-details
|
||||||
clearable
|
clearable
|
||||||
style="min-width: 180px"
|
class="fc-posts-filters__platform"
|
||||||
@update:model-value="emitFilters"
|
@update:model-value="emitFilters"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -135,6 +135,14 @@ watch(() => props.platform, (val) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
padding-bottom: 1rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,42 @@
|
|||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title>Import filters</v-card-title>
|
<v-card-title>Import filters</v-card-title>
|
||||||
<v-card-text v-if="store.settings">
|
<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-row>
|
||||||
<v-col cols="12" sm="6">
|
<v-col cols="12" sm="6">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
@@ -41,14 +77,6 @@
|
|||||||
:disabled="!local.skip_single_color" @end="save"
|
:disabled="!local.skip_single_color" @end="save"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</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-row>
|
||||||
|
|
||||||
<v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable>
|
<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'
|
import { useImportStore } from '../../stores/import.js'
|
||||||
|
|
||||||
const store = useImportStore()
|
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
|
// Downloader + schedule-defaults fields moved to
|
||||||
// /subscriptions?tab=settings (operator decision 2026-05-27). This form
|
// /subscriptions?tab=settings (operator decision 2026-05-27). This form
|
||||||
// now only owns image-import filters.
|
// now only owns image-import filters.
|
||||||
@@ -89,4 +120,11 @@ async function save() {
|
|||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
margin-top: 2px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -89,6 +89,53 @@
|
|||||||
@click="onKindCommit"
|
@click="onKindCommit"
|
||||||
>Delete {{ kindPreview.count }} legacy tag(s)</v-btn>
|
>Delete {{ kindPreview.count }} legacy tag(s)</v-btn>
|
||||||
</div>
|
</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 }})
|
||||||
|
</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-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -105,6 +152,9 @@ const committing = ref(false)
|
|||||||
const kindPreview = ref(null)
|
const kindPreview = ref(null)
|
||||||
const loadingKindPreview = ref(false)
|
const loadingKindPreview = ref(false)
|
||||||
const kindCommitting = ref(false)
|
const kindCommitting = ref(false)
|
||||||
|
const resetPreview = ref(null)
|
||||||
|
const loadingResetPreview = ref(false)
|
||||||
|
const resetCommitting = ref(false)
|
||||||
|
|
||||||
async function onPreview() {
|
async function onPreview() {
|
||||||
loadingPreview.value = true
|
loadingPreview.value = true
|
||||||
@@ -143,6 +193,25 @@ async function onKindCommit() {
|
|||||||
kindCommitting.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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"
|
:items="STATUS_OPTIONS"
|
||||||
:disabled="needsAttention"
|
:disabled="needsAttention"
|
||||||
density="compact" variant="outlined" hide-details
|
density="compact" variant="outlined" hide-details
|
||||||
style="max-width: 180px"
|
class="fc-subs__status"
|
||||||
/>
|
/>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="search"
|
v-model="search"
|
||||||
density="compact" variant="outlined" hide-details clearable
|
density="compact" variant="outlined" hide-details clearable
|
||||||
prepend-inner-icon="mdi-magnify"
|
prepend-inner-icon="mdi-magnify"
|
||||||
placeholder="Search subscriptions"
|
placeholder="Search subscriptions"
|
||||||
style="max-width: 320px"
|
class="fc-subs__search"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
<p v-else>No subscriptions match the current filter.</p>
|
<p v-else>No subscriptions match the current filter.</p>
|
||||||
</div>
|
</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
|
<v-data-table
|
||||||
:headers="headers"
|
:headers="headers"
|
||||||
:items="filteredGroups"
|
:items="filteredGroups"
|
||||||
@@ -189,6 +189,76 @@
|
|||||||
</v-data-table>
|
</v-data-table>
|
||||||
</v-card>
|
</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
|
<SourceFormDialog
|
||||||
v-model="showSourceDialog"
|
v-model="showSourceDialog"
|
||||||
:source="editingSource"
|
:source="editingSource"
|
||||||
@@ -202,11 +272,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { toast } from '../../utils/toast.js'
|
import { toast } from '../../utils/toast.js'
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useSourcesStore } from '../../stores/sources.js'
|
import { useSourcesStore } from '../../stores/sources.js'
|
||||||
import { usePlatformsStore } from '../../stores/platforms.js'
|
import { usePlatformsStore } from '../../stores/platforms.js'
|
||||||
import { useImportStore } from '../../stores/import.js'
|
import { useImportStore } from '../../stores/import.js'
|
||||||
import SourceRow from './SourceRow.vue'
|
import SourceRow from './SourceRow.vue'
|
||||||
|
import SourceCard from './SourceCard.vue'
|
||||||
import SourceHealthDot from './SourceHealthDot.vue'
|
import SourceHealthDot from './SourceHealthDot.vue'
|
||||||
import SourceFormDialog from './SourceFormDialog.vue'
|
import SourceFormDialog from './SourceFormDialog.vue'
|
||||||
import ArtistCreateDialog from './ArtistCreateDialog.vue'
|
import ArtistCreateDialog from './ArtistCreateDialog.vue'
|
||||||
@@ -230,6 +302,10 @@ const STATUS_OPTIONS = [
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
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 store = useSourcesStore()
|
||||||
const platformsStore = usePlatformsStore()
|
const platformsStore = usePlatformsStore()
|
||||||
const importStore = useImportStore()
|
const importStore = useImportStore()
|
||||||
@@ -239,6 +315,19 @@ const statusFilter = ref('all')
|
|||||||
const needsAttention = ref(false)
|
const needsAttention = ref(false)
|
||||||
const expanded = ref([])
|
const expanded = ref([])
|
||||||
const selected = 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 showSourceDialog = ref(false)
|
||||||
const editingSource = ref(null)
|
const editingSource = ref(null)
|
||||||
const editingArtist = ref(null)
|
const editingArtist = ref(null)
|
||||||
@@ -568,6 +657,18 @@ async function bulkDelete() {
|
|||||||
padding: 8px 0 1rem;
|
padding: 8px 0 1rem;
|
||||||
}
|
}
|
||||||
.fc-subs__bar .v-chip { cursor: pointer; }
|
.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 {
|
.fc-subs__loading, .fc-subs__empty {
|
||||||
display: flex; justify-content: center; padding: 2rem;
|
display: flex; justify-content: center; padding: 2rem;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
@@ -575,6 +676,30 @@ async function bulkDelete() {
|
|||||||
.fc-subs__card {
|
.fc-subs__card {
|
||||||
background: rgb(var(--v-theme-surface));
|
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__name { font-weight: 600; }
|
||||||
.fc-subs__chips {
|
.fc-subs__chips {
|
||||||
display: flex; flex-wrap: wrap; gap: 4px;
|
display: flex; flex-wrap: wrap; gap: 4px;
|
||||||
|
|||||||
@@ -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) --------
|
// --- Task progress polling (taps FC-3i activity dashboard) --------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,6 +177,7 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
tagUsageCount,
|
tagUsageCount,
|
||||||
pruneUnusedTags,
|
pruneUnusedTags,
|
||||||
purgeLegacyTags,
|
purgeLegacyTags,
|
||||||
|
resetContentTagging,
|
||||||
pollTaskUntilDone,
|
pollTaskUntilDone,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ export const useCleanupStore = defineStore('cleanup', () => {
|
|||||||
return body.runs
|
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) {
|
async function applyAudit(id, confirm) {
|
||||||
return await api.post(`/api/cleanup/audit/${id}/apply`, { body: { confirm } })
|
return await api.post(`/api/cleanup/audit/${id}/apply`, { body: { confirm } })
|
||||||
}
|
}
|
||||||
@@ -67,6 +75,6 @@ export const useCleanupStore = defineStore('cleanup', () => {
|
|||||||
defaults, recentRuns,
|
defaults, recentRuns,
|
||||||
loadDefaults,
|
loadDefaults,
|
||||||
previewMinDim, deleteMinDim,
|
previewMinDim, deleteMinDim,
|
||||||
startAudit, getAudit, loadHistory, applyAudit, cancelAudit,
|
startAudit, getAudit, loadHistory, latestAuditForRule, applyAudit, cancelAudit,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
// Phase-2 faceted refine params.
|
// Phase-2 faceted refine params.
|
||||||
platform: null, untagged: false, no_artist: false,
|
platform: null, untagged: false, no_artist: false,
|
||||||
date_from: null, date_to: null,
|
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 +
|
// 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) {
|
async function jumpTo(year, month) {
|
||||||
// Rapid timeline-jump clicks need the same race guard as
|
// Rapid timeline-jump clicks need the same race guard as
|
||||||
// loadMore — first jump's late body could clobber the second
|
// 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),
|
no_artist: _truthy(q.no_artist),
|
||||||
date_from: _parseDate(q.date_from),
|
date_from: _parseDate(q.date_from),
|
||||||
date_to: _parseDate(q.date_to),
|
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()
|
_resolveLabels()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +242,7 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
images, dateGroups, hasMore, isEmpty, loading, error,
|
images, dateGroups, hasMore, isEmpty, loading, error,
|
||||||
filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
|
filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
|
||||||
facets, facetsLoading,
|
facets, facetsLoading,
|
||||||
loadInitial, loadMore, loadTimeline, jumpTo, loadFacets,
|
loadInitial, loadMore, loadTimeline, jumpTo, loadFacets, loadSimilar,
|
||||||
applyFilterFromQuery, noteTagLabel, noteArtistLabel,
|
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,
|
tag_ids: [...f.tag_ids], artist_id: f.artist_id, media_type: f.media_type,
|
||||||
sort: f.sort, platform: f.platform, untagged: f.untagged,
|
sort: f.sort, platform: f.platform, untagged: f.untagged,
|
||||||
no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to,
|
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.no_artist) q.no_artist = '1'
|
||||||
if (f.date_from) q.date_from = f.date_from
|
if (f.date_from) q.date_from = f.date_from
|
||||||
if (f.date_to) q.date_to = f.date_to
|
if (f.date_to) q.date_to = f.date_to
|
||||||
|
if (f.similar_to) q.similar_to = String(f.similar_to)
|
||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ export const useModalStore = defineStore('modal', () => {
|
|||||||
const inflight = useInflightToken()
|
const inflight = useInflightToken()
|
||||||
|
|
||||||
// Post-scoped cycle. When set, prev/next cycles within this array
|
// Post-scoped cycle. When set, prev/next cycles within this array
|
||||||
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When
|
// (used by PostCard image clicks — the modal is scoped to that post's
|
||||||
// null, prev/next falls back to current.value.neighbors (the
|
// images). When null, prev/next falls back to current.value.neighbors
|
||||||
// gallery-store-driven /api/gallery/image/<id> neighbors).
|
// (the gallery-store-driven /api/gallery/image/<id> neighbors).
|
||||||
const postImageIds = ref(null)
|
const postImageIds = ref(null)
|
||||||
const postImageIndex = ref(0)
|
const postImageIndex = ref(0)
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import { preloadImage } from '../utils/preloadImage.js'
|
|||||||
// fetch + decode (a cold TABLESAMPLE is a separate, query-side concern);
|
// fetch + decode (a cold TABLESAMPLE is a separate, query-side concern);
|
||||||
// everything after it is buffer-smoothed.
|
// everything after it is buffer-smoothed.
|
||||||
const PAGE = 3
|
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 PRIME = 6 // items buffered before the drain starts
|
||||||
const BUFFER_TARGET = 30 // producer tops the queue up to this
|
const BUFFER_TARGET = 30 // producer tops the queue up to this
|
||||||
const BUFFER_MIN = 12 // ...and refills once the queue dips below this
|
const BUFFER_MIN = 12 // ...and refills once the queue dips below this
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
.fc-artists__grid {
|
.fc-artists__grid {
|
||||||
display: 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;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.fc-artists__sentinel {
|
.fc-artists__sentinel {
|
||||||
|
|||||||
@@ -16,7 +16,10 @@
|
|||||||
<EmptyState v-if="store.isEmpty" />
|
<EmptyState v-if="store.isEmpty" />
|
||||||
<GalleryGrid v-else @open="openImage" />
|
<GalleryGrid v-else @open="openImage" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<BulkEditorPanel />
|
<BulkEditorPanel />
|
||||||
@@ -68,4 +71,9 @@ function openImage(id) {
|
|||||||
.fc-gallery-layout { flex-direction: column-reverse; }
|
.fc-gallery-layout { flex-direction: column-reverse; }
|
||||||
.fc-gallery-layout__sidebar { width: 100%; max-height: 200px; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -44,14 +44,15 @@
|
|||||||
</v-window-item>
|
</v-window-item>
|
||||||
|
|
||||||
<v-window-item value="import">
|
<v-window-item value="import">
|
||||||
<!-- Order: trigger → recent tasks → filters. Tasks sit directly
|
<!-- Order: filters → trigger → recent tasks. Filters hoisted above the
|
||||||
below the trigger so operator sees hit/miss feedback without
|
trigger (operator-flagged 2026-06-04); the task list stays
|
||||||
scrolling past the filter card (operator-flagged 2026-05-25). -->
|
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 />
|
<ImportTriggerPanel />
|
||||||
<v-divider class="my-6" />
|
<v-divider class="my-6" />
|
||||||
<ImportTaskList />
|
<ImportTaskList />
|
||||||
<v-divider class="my-6" />
|
|
||||||
<ImportFiltersForm />
|
|
||||||
</v-window-item>
|
</v-window-item>
|
||||||
|
|
||||||
<v-window-item value="cleanup">
|
<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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,26 +1,61 @@
|
|||||||
// @vitest-environment happy-dom
|
// @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 PostCard from '../../src/components/posts/PostCard.vue'
|
||||||
|
import { useModalStore } from '../../src/stores/modal.js'
|
||||||
import { freshPinia, mountComponent } from '../support/mountComponent.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', () => {
|
describe('PostCard', () => {
|
||||||
it('renders the HTML-stripped title and the artist', () => {
|
it('renders the HTML-stripped title and the artist', () => {
|
||||||
const pinia = freshPinia()
|
const post = { ...BASE, description_plain: 'a description' }
|
||||||
const now = new Date().toISOString()
|
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
|
||||||
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 t = w.text()
|
const t = w.text()
|
||||||
expect(t).toContain('Hello World') // plain title
|
expect(t).toContain('Hello World') // plain title
|
||||||
expect(t).not.toContain('<strong>') // tags stripped
|
expect(t).not.toContain('<strong>') // tags stripped
|
||||||
expect(t).toContain('Sabu') // artist (RouterLink slot)
|
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] })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -122,6 +122,28 @@ describe('gallery store: composable filter', () => {
|
|||||||
expect(s.facets.total).toBe(4)
|
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 () => {
|
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
|
||||||
const s = useGalleryStore()
|
const s = useGalleryStore()
|
||||||
const urls = []
|
const urls = []
|
||||||
@@ -154,6 +176,11 @@ describe('filterToQuery / cloneFilter', () => {
|
|||||||
expect(q.sort).toBeUndefined() // newest is the default → omitted
|
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', () => {
|
it('cloneFilter copies refine fields and detaches tag_ids', () => {
|
||||||
const orig = {
|
const orig = {
|
||||||
tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',
|
tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ describe('showcase store: buffered cascade', () => {
|
|||||||
stubShowcase()
|
stubShowcase()
|
||||||
const s = useShowcaseStore()
|
const s = useShowcaseStore()
|
||||||
const p = s.loadInitial()
|
const p = s.loadInitial()
|
||||||
// Past INITIAL_COUNT (60) × CADENCE (80ms) plus fetch microtasks.
|
// Past INITIAL_COUNT (60) × CADENCE (160ms = 9.6s) plus fetch microtasks.
|
||||||
await vi.advanceTimersByTimeAsync(6000)
|
await vi.advanceTimersByTimeAsync(12000)
|
||||||
await p
|
await p
|
||||||
expect(s.images.length).toBe(60)
|
expect(s.images.length).toBe(60)
|
||||||
const ids = s.images.map((i) => i.id)
|
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.
|
// the cadence — a 1s window must not dump the whole buffer at once.
|
||||||
expect(early).toBeGreaterThan(0)
|
expect(early).toBeGreaterThan(0)
|
||||||
expect(early).toBeLessThan(60)
|
expect(early).toBeLessThan(60)
|
||||||
await vi.advanceTimersByTimeAsync(6000)
|
await vi.advanceTimersByTimeAsync(12000)
|
||||||
await p
|
await p
|
||||||
expect(s.images.length).toBe(60)
|
expect(s.images.length).toBe(60)
|
||||||
})
|
})
|
||||||
@@ -80,7 +80,7 @@ describe('showcase store: buffered cascade', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(1000)
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
expect(s.images.length).toBe(0) // gated on the un-decoded first image
|
expect(s.images.length).toBe(0) // gated on the un-decoded first image
|
||||||
release()
|
release()
|
||||||
await vi.advanceTimersByTimeAsync(6000)
|
await vi.advanceTimersByTimeAsync(12000)
|
||||||
await p
|
await p
|
||||||
expect(s.images.length).toBe(60) // cascade proceeds once it decodes
|
expect(s.images.length).toBe(60) // cascade proceeds once it decodes
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -435,3 +435,26 @@ async def test_trigger_vacuum_queues_the_task(client, monkeypatch):
|
|||||||
resp = await client.post("/api/admin/maintenance/vacuum")
|
resp = await client.post("/api/admin/maintenance/vacuum")
|
||||||
assert resp.status_code == 202
|
assert resp.status_code == 202
|
||||||
assert calls == [1]
|
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
|
||||||
|
|||||||
@@ -155,6 +155,27 @@ async def test_audit_history_returns_recent_runs(client, db):
|
|||||||
assert len(body["runs"]) >= 3
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_audit_apply_with_token_deletes(client, db, tmp_path):
|
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")
|
rec = await _seed_image(db, tmp_path, w=100, h=100, name="apply.png")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import pytest
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from backend.app.models import Artist, ImageRecord, Tag, TagKind
|
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.models.tag import image_tag
|
||||||
from backend.app.services import cleanup_service
|
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()
|
surviving_names = db_sync.execute(select(Tag.name)).scalars().all()
|
||||||
assert "kept" in surviving_names
|
assert "kept" in surviving_names
|
||||||
assert "bye" not 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]
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user