Merge pull request 'Gallery speed + fandom editing + filters + pinned filter bar' (#61) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 18s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 11s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m29s

This commit was merged in pull request #61.
This commit is contained in:
2026-06-04 00:07:19 -04:00
27 changed files with 1208 additions and 175 deletions
+3
View File
@@ -61,6 +61,9 @@ Thumbs.db
# Claude Code per-user local overrides (shared .claude/settings.json is OK to commit)
.claude/settings.local.json
# Transient scheduler lock/state (committed by accident in 3f30327)
.claude/scheduled_tasks.lock
.claude/scheduled_tasks*.json
# Alembic / DB scratch
alembic/versions/__pycache__/
@@ -0,0 +1,70 @@
"""image_record.effective_date: materialized gallery sort key + index
Revision ID: 0035
Revises: 0034
Create Date: 2026-06-04
The gallery ordered/cursored on COALESCE(post.post_date,
image_record.created_at) across the Post outer join. That expression spans
two tables, so no index can serve it — every /scroll sorted a large slice
of the library, and the frontend fired ten of them serially per initial
load. Materialize the value into image_record.effective_date and index
(effective_date DESC, id DESC) so the cursor scroll is an index range scan.
Backfill = COALESCE(primary post's post_date, created_at) so existing rows
keep their exact ordering. New rows get the created_at-equivalent server
default; services/importer.py overrides it with the post's date when a
primary post with a date is linked.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0035"
down_revision: Union[str, None] = "0034"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add nullable first so the backfill can populate before NOT NULL.
op.add_column(
"image_record",
sa.Column("effective_date", sa.DateTime(timezone=True), nullable=True),
)
# Pure set-based UPDATEs (no per-row params) — immune to the 65535
# bind-parameter ceiling regardless of library size.
op.execute(
"""
UPDATE image_record AS ir
SET effective_date = COALESCE(p.post_date, ir.created_at)
FROM post AS p
WHERE ir.primary_post_id = p.id
"""
)
op.execute(
"""
UPDATE image_record
SET effective_date = created_at
WHERE effective_date IS NULL
"""
)
op.alter_column(
"image_record",
"effective_date",
nullable=False,
server_default=sa.text("now()"),
)
# DESC/DESC matches the gallery's ORDER BY effective_date DESC, id DESC
# so the scroll is a forward index scan; raw SQL because alembic's
# column list doesn't express per-column DESC cleanly.
op.execute(
"CREATE INDEX ix_image_record_effective_date "
"ON image_record (effective_date DESC, id DESC)"
)
def downgrade() -> None:
op.drop_index("ix_image_record_effective_date", table_name="image_record")
op.drop_column("image_record", "effective_date")
+33 -22
View File
@@ -8,26 +8,42 @@ from ..services.gallery_service import GalleryService
gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")
def _parse_filters():
"""Parse the composable gallery filters from query args. Raises
ValueError (→ 400) on malformed ids. `tag_id` accepts a single id or a
comma-separated list (AND); `media` is image|video; `sort` is
newest|oldest."""
tag_raw = request.args.get("tag_id")
tag_ids = (
[int(x) for x in tag_raw.split(",") if x.strip()] if tag_raw else None
) or None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
media = request.args.get("media")
media_type = media if media in ("image", "video") else None
sort = request.args.get("sort")
sort = sort if sort in ("newest", "oldest") else "newest"
return tag_ids, post_id, artist_id, media_type, sort
@gallery_bp.route("/scroll", methods=["GET"])
async def scroll():
cursor = request.args.get("cursor") or None
try:
limit = int(request.args.get("limit", "50"))
tag_ids, post_id, artist_id, media_type, sort = _parse_filters()
except ValueError:
return jsonify({"error": "limit must be an integer"}), 400
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
return jsonify({"error": "invalid filter or limit parameter"}), 400
async with get_session() as session:
svc = GalleryService(session)
try:
page = await svc.scroll(
cursor=cursor, limit=limit, tag_id=tag_id,
cursor=cursor, limit=limit, tag_ids=tag_ids,
post_id=post_id, artist_id=artist_id,
media_type=media_type, sort=sort,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
@@ -58,17 +74,16 @@ async def scroll():
@gallery_bp.route("/timeline", methods=["GET"])
async def timeline():
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
try:
tag_ids, post_id, artist_id, media_type, _sort = _parse_filters()
except ValueError:
return jsonify({"error": "invalid filter parameter"}), 400
async with get_session() as session:
svc = GalleryService(session)
try:
buckets = await svc.timeline(
tag_id=tag_id, post_id=post_id, artist_id=artist_id
tag_ids=tag_ids, post_id=post_id, artist_id=artist_id,
media_type=media_type,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
@@ -82,20 +97,16 @@ async def jump():
try:
year = int(request.args["year"])
month = int(request.args["month"])
tag_ids, post_id, artist_id, media_type, sort = _parse_filters()
except (KeyError, ValueError):
return jsonify({"error": "year and month query params required"}), 400
tag_id_raw = request.args.get("tag_id")
tag_id = int(tag_id_raw) if tag_id_raw else None
post_id_raw = request.args.get("post_id")
post_id = int(post_id_raw) if post_id_raw else None
artist_id_raw = request.args.get("artist_id")
artist_id = int(artist_id_raw) if artist_id_raw else None
async with get_session() as session:
svc = GalleryService(session)
try:
cursor = await svc.jump_cursor(
year=year, month=month, tag_id=tag_id,
year=year, month=month, tag_ids=tag_ids,
post_id=post_id, artist_id=artist_id,
media_type=media_type, sort=sort,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
+42 -6
View File
@@ -194,15 +194,46 @@ async def remove_tag_from_image(image_id: int, tag_id: int):
return "", 204
@tags_bp.route("/tags/<int:tag_id>", methods=["GET"])
async def get_tag(tag_id: int):
"""Resolve a single tag (used by the gallery to label its active
tag-filter chip)."""
async with get_session() as session:
tag = await session.get(Tag, tag_id)
if tag is None:
return jsonify({"error": "tag not found"}), 404
return jsonify(
{
"id": tag.id,
"name": tag.name,
"kind": tag.kind.value,
"fandom_id": tag.fandom_id,
}
)
@tags_bp.route("/tags/<int:tag_id>", methods=["PATCH"])
async def rename_tag(tag_id: int):
body = await request.get_json()
if not body or "name" not in body:
return jsonify({"error": "name required"}), 400
async def update_tag(tag_id: int):
"""Rename and/or re-fandom a tag. Body may carry `name` and/or
`fandom_id` (a fandom tag id, or null to clear — character tags only).
`merge: true` resolves a collision by merging into the existing tag.
"""
body = await request.get_json() or {}
has_name = "name" in body
has_fandom = "fandom_id" in body
if not has_name and not has_fandom:
return jsonify({"error": "name or fandom_id required"}), 400
do_merge = bool(body.get("merge"))
async with get_session() as session:
svc = TagService(session)
try:
tag = await svc.rename(tag_id, body["name"])
tag = None
if has_name:
tag = await svc.rename(tag_id, body["name"])
if has_fandom:
tag = await svc.set_fandom(
tag_id, body["fandom_id"], merge=do_merge
)
except TagMergeConflict as exc:
return jsonify(
{
@@ -219,7 +250,12 @@ async def rename_tag(tag_id: int):
return jsonify({"error": str(exc)}), 400
await session.commit()
return jsonify(
{"id": tag.id, "name": tag.name, "kind": tag.kind.value}
{
"id": tag.id,
"name": tag.name,
"kind": tag.kind.value,
"fandom_id": tag.fandom_id,
}
)
+11
View File
@@ -74,6 +74,17 @@ class ImageRecord(Base):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Denormalized gallery sort key = COALESCE(primary post's post_date,
# created_at) (alembic 0035). The gallery used to compute this as a
# COALESCE across the Post outer join on every /scroll, which can't use
# an index and re-sorted a large slice of the library per page (×10 with
# the old serial batching). Materializing it lets the cursor scroll read
# ix_image_record_effective_date directly. Maintained by the importer
# (services/importer.py _apply_sidecar) when a primary post with a date
# is linked; plain inserts keep the created_at-equivalent server default.
effective_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
+96 -63
View File
@@ -43,16 +43,17 @@ def decode_cursor(cursor: str) -> tuple[datetime, int]:
def _effective_date_col():
"""SQL expression: COALESCE(post.post_date, image_record.created_at).
"""The materialized gallery sort key: image_record.effective_date
(alembic 0035) = COALESCE(primary post's post_date, created_at),
maintained at write time by the importer.
Used as the canonical sort/group/filter key across the gallery so
images backfilled with primary_post_id (e.g. via tag_apply phase 4)
surface at their original publish date, not their FC import date.
Images without a Post (or with Post.post_date NULL) fall back to
image_record.created_at and still order coherently against
post-attached ones.
Canonical sort/group/filter key across the gallery so images attached
to a post surface at their original publish date, not their FC import
date — and, now that it's a single indexed column rather than a
COALESCE across the Post outer join, the cursor scroll is an index
range scan instead of a full re-sort per page.
"""
return func.coalesce(Post.post_date, ImageRecord.created_at)
return ImageRecord.effective_date
def _outer_join_primary_post(stmt: Select) -> Select:
@@ -117,13 +118,43 @@ def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str
return f"/images/thumbs/{bucket}/{sha256_hex}{ext}"
def _require_single_filter(tag_id, post_id, artist_id) -> None:
if sum(x is not None for x in (tag_id, post_id, artist_id)) > 1:
def _require_single_filter(tag_ids, post_id, artist_id) -> None:
"""post_id is the post-detail view — it can't combine with the
composable filters. tag_ids + artist_id (+ media_type) compose freely
(AND)."""
if post_id is not None and (tag_ids or artist_id is not None):
raise ValueError(
"tag_id, post_id, artist_id are mutually exclusive"
"post_id cannot be combined with tag or artist filters"
)
def _apply_scope(stmt, *, tag_ids, post_id, artist_id, media_type):
"""Apply the composable gallery filters to a statement already joined
to Post via _outer_join_primary_post.
- tag_ids: image must carry ALL of them — one correlated EXISTS per tag
(AND), which avoids the row-multiplication a multi-join would cause.
- post_id / artist_id: provenance EXISTS (post_id is exclusive, guarded
by _require_single_filter).
- media_type: 'image' | 'video' narrows by mime prefix.
"""
for tid in tag_ids or []:
stmt = stmt.where(
exists().where(
image_tag.c.image_record_id == ImageRecord.id,
image_tag.c.tag_id == tid,
)
)
prov = _provenance_clause(post_id, artist_id)
if prov is not None:
stmt = stmt.where(prov)
if media_type == "image":
stmt = stmt.where(ImageRecord.mime.like("image/%"))
elif media_type == "video":
stmt = stmt.where(ImageRecord.mime.like("video/%"))
return stmt
def _provenance_clause(post_id, artist_id):
"""Correlated EXISTS clause (NOT a join) so an image with multiple
matching provenance rows is returned exactly once and the
@@ -179,35 +210,43 @@ class GalleryService:
self,
cursor: str | None,
limit: int = 50,
tag_id: int | None = None,
tag_ids: list[int] | None = None,
post_id: int | None = None,
artist_id: int | None = None,
media_type: str | None = None,
sort: str = "newest",
) -> GalleryPage:
if limit < 1 or limit > 200:
raise ValueError("limit must be between 1 and 200")
_require_single_filter(tag_id, post_id, artist_id)
_require_single_filter(tag_ids, post_id, artist_id)
eff = _effective_date_col()
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
stmt = _outer_join_primary_post(stmt)
if tag_id is not None:
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
image_tag.c.tag_id == tag_id
)
prov = _provenance_clause(post_id, artist_id)
if prov is not None:
stmt = stmt.where(prov)
stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type,
)
descending = sort != "oldest"
if cursor:
cur_ts, cur_id = decode_cursor(cursor)
stmt = stmt.where(
or_(
eff < cur_ts,
and_(eff == cur_ts, ImageRecord.id < cur_id),
# The cursor is just (last eff, last id); the request's sort
# decides which side of it the next page lies on.
if descending:
stmt = stmt.where(
or_(eff < cur_ts, and_(eff == cur_ts, ImageRecord.id < cur_id))
)
else:
stmt = stmt.where(
or_(eff > cur_ts, and_(eff == cur_ts, ImageRecord.id > cur_id))
)
)
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(limit + 1)
if descending:
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc())
else:
stmt = stmt.order_by(eff.asc(), ImageRecord.id.asc())
stmt = stmt.limit(limit + 1)
rows = (await self.session.execute(stmt)).all()
next_cursor = None
@@ -243,9 +282,10 @@ class GalleryService:
async def timeline(
self,
tag_id: int | None = None,
tag_ids: list[int] | None = None,
post_id: int | None = None,
artist_id: int | None = None,
media_type: str | None = None,
) -> list[TimelineBucket]:
eff = _effective_date_col()
year_col = func.date_part("year", eff).label("yr")
@@ -254,25 +294,23 @@ class GalleryService:
year_col, month_col, func.count(ImageRecord.id).label("cnt")
)
stmt = _outer_join_primary_post(stmt)
_require_single_filter(tag_id, post_id, artist_id)
if tag_id is not None:
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
image_tag.c.tag_id == tag_id
)
prov = _provenance_clause(post_id, artist_id)
if prov is not None:
stmt = stmt.where(prov)
_require_single_filter(tag_ids, post_id, artist_id)
stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type,
)
stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc())
rows = (await self.session.execute(stmt)).all()
return [TimelineBucket(year=int(r.yr), month=int(r.mo), count=int(r.cnt)) for r in rows]
async def jump_cursor(
self, year: int, month: int, tag_id: int | None = None,
self, year: int, month: int, tag_ids: list[int] | None = None,
post_id: int | None = None, artist_id: int | None = None,
media_type: str | None = None, sort: str = "newest",
) -> str | None:
"""Returns a cursor that, when passed to scroll(), positions at the
first image of the given year-month (by effective_date, not
created_at). None if the bucket is empty.
"""Returns a cursor that, when passed to scroll() with the same sort,
positions at the first image of the given year-month. None if the
bucket is empty.
"""
from sqlalchemy import extract
@@ -282,22 +320,24 @@ class GalleryService:
extract("month", eff) == month,
)
stmt = _outer_join_primary_post(stmt)
_require_single_filter(tag_id, post_id, artist_id)
if tag_id is not None:
stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where(
image_tag.c.tag_id == tag_id
)
prov = _provenance_clause(post_id, artist_id)
if prov is not None:
stmt = stmt.where(prov)
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc()).limit(1)
first = (await self.session.execute(stmt)).first()
_require_single_filter(tag_ids, post_id, artist_id)
stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=post_id,
artist_id=artist_id, media_type=media_type,
)
descending = sort != "oldest"
if descending:
stmt = stmt.order_by(eff.desc(), ImageRecord.id.desc())
else:
stmt = stmt.order_by(eff.asc(), ImageRecord.id.asc())
first = (await self.session.execute(stmt.limit(1))).first()
if first is None:
return None
record, eff_date = first
# Cursor is exclusive; we encode a cursor with id+1 so the row itself
# is the first result in the next scroll().
return encode_cursor(eff_date, record.id + 1)
# Cursor is exclusive; nudge the id one past the boundary row (in the
# scan direction) so the row itself is the first result of scroll().
boundary = record.id + 1 if descending else record.id - 1
return encode_cursor(eff_date, boundary)
async def get_image_with_tags(self, image_id: int) -> dict | None:
record = await self.session.get(ImageRecord, image_id)
@@ -357,17 +397,10 @@ class GalleryService:
}
async def _neighbors(self, record: ImageRecord) -> dict:
# Compute the boundary image's effective_date in Python (one query
# below + the SELECT we already have on `record`) and use it for
# the neighbor comparison. Cheaper than re-deriving in SQL via
# correlated subquery.
boundary_eff = record.created_at
if record.primary_post_id is not None:
post_date = (await self.session.execute(
select(Post.post_date).where(Post.id == record.primary_post_id)
)).scalar_one_or_none()
if post_date is not None:
boundary_eff = post_date
# The boundary image's sort key is materialized on the row now
# (alembic 0035) — read it directly instead of re-deriving COALESCE
# via an extra Post lookup.
boundary_eff = record.effective_date
eff = _effective_date_col()
prev_stmt = _outer_join_primary_post(
+8
View File
@@ -960,6 +960,14 @@ class Importer:
sp.rollback()
if record.primary_post_id is None:
record.primary_post_id = post.id
# Keep the denormalized gallery sort key (alembic 0035) aligned with
# the primary post's publish date so /scroll orders off
# ix_image_record_effective_date instead of COALESCE-ing across the
# post join. Only override when THIS post is the primary AND carries
# a date; otherwise the column keeps its created_at-equivalent server
# default (matches the old COALESCE(post_date, created_at) fallback).
if record.primary_post_id == post.id and post.post_date is not None:
record.effective_date = post.post_date
self.session.flush()
def _copy_to_library(
+70
View File
@@ -280,6 +280,69 @@ class TagService:
await self.session.flush()
return tag
async def set_fandom(
self, tag_id: int, fandom_id: int | None, *, merge: bool = False
) -> Tag:
"""Set / change / clear a character tag's fandom.
Raises TagValidationError unless the tag is a character and fandom_id
(when given) references a fandom tag. If the change would collide with
an existing character of the same name in the TARGET fandom, raises
TagMergeConflict (the API turns that into a 409 merge hint) — unless
merge=True, in which case this tag is merged INTO that existing
character (a deliberate cross-fandom merge) and the surviving target
is returned. Passing fandom_id=None clears the fandom.
"""
tag = await self.session.get(Tag, tag_id)
if tag is None:
raise TagValidationError(f"Tag {tag_id} not found")
if tag.kind != TagKind.character:
raise TagValidationError("Only character tags can have a fandom")
if fandom_id is not None:
fandom = await self.session.get(Tag, fandom_id)
if fandom is None or fandom.kind != TagKind.fandom:
raise TagValidationError(
f"fandom_id {fandom_id} does not reference a fandom tag"
)
if fandom_id == tag.fandom_id:
return tag
# Collision: another character with the same name already lives in the
# target fandom. Mirrors rename's (name, kind, fandom_id) uniqueness.
clash_stmt = (
select(Tag)
.where(Tag.name == tag.name)
.where(Tag.kind == TagKind.character)
.where(
Tag.fandom_id.is_(None)
if fandom_id is None
else Tag.fandom_id == fandom_id
)
.where(Tag.id != tag_id)
)
clash = (await self.session.execute(clash_stmt)).scalar_one_or_none()
if clash is not None:
if not merge:
source_image_count = await self.session.scalar(
select(func.count())
.select_from(image_tag)
.where(image_tag.c.tag_id == tag_id)
)
will_alias = await self._keep_as_alias(tag_id)
raise TagMergeConflict(
f"A character named {tag.name!r} already exists in that fandom",
target_id=clash.id,
target_name=clash.name,
source_image_count=int(source_image_count or 0),
will_alias=will_alias,
)
await self._do_merge(tag, clash)
return clash
tag.fandom_id = fandom_id
await self.session.flush()
return tag
async def merge(self, source_id: int, target_id: int) -> MergeResult:
"""Transactionally repoint every FK from source→target, optionally
keep source's name as a tagger alias, delete source. Atomic: any
@@ -298,7 +361,14 @@ class TagService:
raise TagValidationError(
"Tags must be the same kind and fandom to merge"
)
return await self._do_merge(source, target)
async def _do_merge(self, source: Tag, target: Tag) -> MergeResult:
"""Repoint every FK source→target, optionally keep source's name as a
tagger alias, delete source. NO kind/fandom validation — callers that
need it (public merge()) validate first; set_fandom's collision
resolution calls this directly for a deliberate CROSS-fandom merge."""
source_id, target_id = source.id, target.id
keep_as_alias = await self._keep_as_alias(source_id)
source_name = source.name
source_kind = source.kind
@@ -55,6 +55,12 @@
/>
</template>
<v-list density="compact">
<v-list-item
v-if="card.kind === 'character'"
title="Set fandom…"
prepend-icon="mdi-book-open-page-variant"
@click="$emit('set-fandom', card)"
/>
<v-list-item
title="Merge with…"
prepend-icon="mdi-call-merge"
@@ -78,7 +84,9 @@
import { ref } from 'vue'
const props = defineProps({ card: { type: Object, required: true } })
const emit = defineEmits(['open', 'rename', 'manage', 'read', 'merge-with', 'delete'])
const emit = defineEmits([
'open', 'rename', 'manage', 'read', 'merge-with', 'delete', 'set-fandom',
])
const editing = ref(false)
const draft = ref('')
@@ -0,0 +1,202 @@
<template>
<div class="fc-filterbar">
<v-autocomplete
v-model="selected"
:items="searchItems"
:loading="searchLoading"
item-title="name" item-value="value"
no-filter clearable hide-details density="compact" variant="outlined"
placeholder="Filter by tag or artist…"
prepend-inner-icon="mdi-filter-variant"
class="fc-filterbar__search"
@update:search="onSearch"
@update:model-value="onPick"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps" :title="item.raw.name">
<template #prepend>
<v-icon size="small">{{ iconFor(item.raw) }}</v-icon>
</template>
<template #subtitle>
{{ item.raw.kind === 'artist' ? 'artist'
: (item.raw.fandom_name ? `character · ${item.raw.fandom_name}` : item.raw.kind) }}
</template>
</v-list-item>
</template>
</v-autocomplete>
<div class="fc-filterbar__chips">
<v-chip
v-for="id in store.filter.tag_ids" :key="`t${id}`"
size="small" closable :color="chipColor(id)" variant="tonal"
@click:close="removeTag(id)"
>{{ store.tagLabels[id] || `#${id}` }}</v-chip>
<v-chip
v-if="store.filter.artist_id"
size="small" closable color="accent" variant="tonal"
prepend-icon="mdi-account"
@click:close="clearArtist"
>{{ store.artistLabel || `Artist #${store.filter.artist_id}` }}</v-chip>
</div>
<v-spacer />
<v-btn-toggle
:model-value="store.filter.media_type ?? 'all'"
density="compact" mandatory variant="outlined" divided
@update:model-value="(v) => setMedia(v === 'all' ? null : v)"
>
<v-btn value="all" size="small">All</v-btn>
<v-btn value="image" size="small">Images</v-btn>
<v-btn value="video" size="small">Videos</v-btn>
</v-btn-toggle>
<v-select
:model-value="store.filter.sort"
:items="SORTS"
density="compact" hide-details variant="outlined"
class="fc-filterbar__sort"
@update:model-value="setSort"
/>
<v-btn
v-if="hasActiveFilters" variant="text" size="small"
prepend-icon="mdi-close" @click="clearAll"
>Clear</v-btn>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useApi } from '../../composables/useApi.js'
import { useGalleryStore } from '../../stores/gallery.js'
import { useTagStore } from '../../stores/tags.js'
const store = useGalleryStore()
const tagStore = useTagStore()
const api = useApi()
const router = useRouter()
const SORTS = [
{ title: 'Newest first', value: 'newest' },
{ title: 'Oldest first', value: 'oldest' },
]
const selected = ref(null)
const searchItems = ref([])
const searchLoading = ref(false)
let debounce = null
const hasActiveFilters = computed(() =>
store.filter.tag_ids.length > 0 ||
store.filter.artist_id != null ||
store.filter.media_type != null ||
store.filter.sort !== 'newest'
)
function iconFor(raw) {
if (raw.kind === 'artist') return 'mdi-account'
return { character: 'mdi-account-circle', fandom: 'mdi-book-open-page-variant',
series: 'mdi-bookshelf' }[raw.kind] || 'mdi-tag'
}
function chipColor(id) {
// Tag chips use the same per-kind palette as the rest of the app; we only
// know the kind from the autocomplete pick, so fall back to a neutral tone.
return tagStore.colorFor(pickedKind.value[id] || 'general')
}
const pickedKind = ref({})
function onSearch(q) {
if (debounce) clearTimeout(debounce)
if (!q || !q.trim()) { searchItems.value = []; return }
debounce = setTimeout(async () => {
searchLoading.value = true
try {
const [tags, artists] = await Promise.all([
api.get('/api/tags/autocomplete', { params: { q, limit: 10 } }),
api.get('/api/artists/autocomplete', { params: { q, limit: 10 } }),
])
searchItems.value = [
...(artists || []).map((a) => ({
kind: 'artist', id: a.id, name: a.name, value: `artist:${a.id}`,
})),
...(tags || []).map((t) => ({
kind: t.kind, id: t.id, name: t.name, value: `tag:${t.id}`,
fandom_name: t.fandom_name,
})),
]
} catch {
searchItems.value = []
} finally {
searchLoading.value = false
}
}, 250)
}
function onPick(value) {
const item = searchItems.value.find((i) => i.value === value)
selected.value = null
searchItems.value = []
if (!item) return
if (item.kind === 'artist') {
store.noteArtistLabel(item.name)
pushFilter((n) => { n.artist_id = item.id })
} else {
store.noteTagLabel(item.id, item.name)
pickedKind.value = { ...pickedKind.value, [item.id]: item.kind }
pushFilter((n) => { if (!n.tag_ids.includes(item.id)) n.tag_ids.push(item.id) })
}
}
function removeTag(id) {
pushFilter((n) => { n.tag_ids = n.tag_ids.filter((t) => t !== id) })
}
function clearArtist() {
store.noteArtistLabel(null)
pushFilter((n) => { n.artist_id = null })
}
function setMedia(m) { pushFilter((n) => { n.media_type = m }) }
function setSort(s) { pushFilter((n) => { n.sort = s }) }
function clearAll() { router.push({ name: 'gallery', query: {} }) }
// Single write path: clone the current filter, mutate, serialize to the URL.
// The route watcher in GalleryView applies it to the store and reloads.
function pushFilter(mutate) {
const f = store.filter
const n = {
tag_ids: [...f.tag_ids],
artist_id: f.artist_id,
media_type: f.media_type,
sort: f.sort,
}
mutate(n)
const q = {}
if (n.tag_ids.length) q.tag_id = n.tag_ids.join(',')
if (n.artist_id) q.artist_id = String(n.artist_id)
if (n.media_type) q.media = n.media_type
if (n.sort && n.sort !== 'newest') q.sort = n.sort
router.push({ name: 'gallery', query: q })
}
</script>
<style scoped>
/* Pinned under the 64px TopNav, matching the app's sticky v-tabs chrome
(SettingsView / ArtistHeader / SubscriptionsView). */
.fc-filterbar {
position: sticky;
top: 64px;
z-index: 4;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 8px 4px;
margin-bottom: 12px;
background: rgb(var(--v-theme-surface));
border-bottom: 1px solid rgb(var(--v-theme-surface-light));
}
.fc-filterbar__search { max-width: 320px; min-width: 200px; }
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.fc-filterbar__sort { max-width: 150px; }
</style>
@@ -9,11 +9,16 @@
>
<div class="fc-gallery-item__media">
<img
v-if="!isVideo" :src="image.thumbnail_url" :alt="`Image ${image.id}`"
loading="lazy" @error="onThumbError"
v-if="!isVideo" ref="imgEl" :src="image.thumbnail_url"
:alt="`Image ${image.id}`" loading="lazy"
:class="{ 'is-loaded': loaded }"
@load="loaded = true" @error="onThumbError"
>
<div v-else class="fc-gallery-item__video-thumb">
<img :src="image.thumbnail_url" :alt="`Video ${image.id}`" loading="lazy">
<img
ref="imgEl" :src="image.thumbnail_url" :alt="`Video ${image.id}`"
loading="lazy" :class="{ 'is-loaded': loaded }" @load="loaded = true"
>
<v-icon class="fc-gallery-item__video-badge" icon="mdi-play-circle" />
</div>
<div v-if="thumbError" class="fc-gallery-item__placeholder">
@@ -38,7 +43,7 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useGallerySelectionStore } from '../../stores/gallerySelection.js'
const props = defineProps({ image: { type: Object, required: true } })
@@ -46,6 +51,18 @@ const emit = defineEmits(['open'])
const sel = useGallerySelectionStore()
const thumbError = ref(false)
// Reveal each tile when ITS OWN thumbnail finishes loading (`@load`), not
// when its metadata batch lands — so tiles fade/flip in individually in
// load order instead of popping in together (operator-flagged 2026-06-04).
const loaded = ref(false)
const imgEl = ref(null)
onMounted(() => {
// A cached thumbnail can already be complete before @load binds; reflect
// that so the tile reveals instead of sitting invisible at opacity 0.
const el = imgEl.value
if (el && el.complete && el.naturalWidth > 0) loaded.value = true
})
const isVideo = computed(
() => props.image.mime && props.image.mime.startsWith('video/')
)
@@ -82,6 +99,30 @@ function onThumbError() { thumbError.value = true }
}
.fc-gallery-item__media img {
width: 100%; height: 100%; object-fit: cover; display: block;
opacity: 0;
}
/* Entrance: each thumbnail flips up out of a slight backward tilt and
settles into place, played WHEN ITS OWN image finishes loading (the
`is-loaded` class) rather than on a batch/timer — so tiles cascade in
natural load order instead of popping in together. Mirrors the showcase
MasonryGrid entrance styling (operator-flagged 2026-06-04). */
.fc-gallery-item__media img.is-loaded {
animation: fc-gallery-item-in 0.5s cubic-bezier(0.34, 1.45, 0.64, 1) both;
}
@keyframes fc-gallery-item-in {
0% {
opacity: 0;
transform: perspective(1000px) rotateX(-22deg) translateY(20px) scale(0.96);
}
55% { opacity: 1; }
100% {
opacity: 1;
transform: perspective(1000px) rotateX(0) translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.fc-gallery-item__media img { opacity: 1; }
.fc-gallery-item__media img.is-loaded { animation: none; }
}
.fc-gallery-item__video-thumb { position: relative; height: 100%; }
.fc-gallery-item__video-badge {
@@ -0,0 +1,128 @@
<template>
<v-card>
<v-card-title class="text-body-1">Fandom for {{ tag.name }}</v-card-title>
<v-card-text>
<template v-if="!collision">
<v-autocomplete
v-model="selectedId"
:items="store.fandomCache"
:item-title="(f) => f.name" :item-value="(f) => f.id"
label="Fandom" clearable density="compact"
:hint="selectedId == null
? 'No fandom — the character will be unassigned.' : ''"
persistent-hint
/>
<v-divider class="my-3" />
<p class="text-caption mb-2">Or create a new fandom:</p>
<div class="d-flex" style="gap: 8px;">
<v-text-field
v-model="newName" placeholder="New fandom name"
density="compact" hide-details
@keydown.enter.prevent="onCreate"
/>
<v-btn
:disabled="!newName.trim() || busy" rounded="pill"
@click="onCreate"
>Create</v-btn>
</div>
<v-alert
v-if="error" type="error" variant="tonal" density="compact"
class="mt-3"
>{{ error }}</v-alert>
</template>
<template v-else>
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
A character named “{{ tag.name }}” already exists in that fandom.
</v-alert>
<p class="text-body-2">
Merge this tag into “{{ collision.target.name }}”?
{{ collision.source_image_count }} image
association{{ collision.source_image_count === 1 ? '' : 's' }}
will move over and this tag will be deleted{{
collision.will_alias ? ' (its name kept as a tagger alias)' : '' }}.
</p>
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<template v-if="!collision">
<v-btn variant="text" :disabled="busy" @click="$emit('cancel')">
Cancel
</v-btn>
<v-btn
color="primary" rounded="pill" :loading="busy"
:disabled="selectedId === (tag.fandom_id ?? null)"
@click="onSave"
>Save</v-btn>
</template>
<template v-else>
<v-btn variant="text" :disabled="busy" @click="collision = null">
Back
</v-btn>
<v-btn
color="warning" variant="flat" rounded="pill" :loading="busy"
@click="onConfirmMerge"
>Merge</v-btn>
</template>
</v-card-actions>
</v-card>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useTagStore } from '../../stores/tags.js'
const props = defineProps({ tag: { type: Object, required: true } })
const emit = defineEmits(['updated', 'cancel'])
const store = useTagStore()
const selectedId = ref(props.tag.fandom_id ?? null)
const newName = ref('')
const busy = ref(false)
const error = ref(null)
const collision = ref(null)
onMounted(() => { if (store.fandomCache.length === 0) store.loadFandoms() })
async function onCreate() {
const name = newName.value.trim()
if (!name) return
busy.value = true
error.value = null
try {
const f = await store.createFandom(name)
selectedId.value = f.id
newName.value = ''
} catch (e) {
error.value = e.message || String(e)
} finally {
busy.value = false
}
}
async function save(merge) {
busy.value = true
error.value = null
try {
const body = await store.setFandom(props.tag.id, selectedId.value, { merge })
emit('updated', body)
} catch (e) {
// 409 on first attempt → surface the merge confirmation; cross-fandom
// collisions can't go through the regular /merge endpoint, so the
// resolution is a second setFandom with merge: true.
if (!merge && e.status === 409 && e.body && e.body.target) {
collision.value = e.body
} else {
error.value = e.message || String(e)
collision.value = null
}
} finally {
busy.value = false
}
}
function onSave() { save(false) }
function onConfirmMerge() { save(true) }
</script>
@@ -28,6 +28,11 @@
<v-list-item @click="openRename(tag)">
<v-list-item-title>Rename…</v-list-item-title>
</v-list-item>
<v-list-item
v-if="tag.kind === 'character'" @click="openSetFandom(tag)"
>
<v-list-item-title>Set fandom…</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
@@ -57,6 +62,13 @@
@renamed="onRenamed" @cancel="renameDialog = false"
/>
</v-dialog>
<v-dialog v-model="fandomDialog" max-width="460">
<FandomSetDialog
v-if="fandomTarget" :tag="fandomTarget"
@updated="onFandomUpdated" @cancel="fandomDialog = false"
/>
</v-dialog>
</aside>
</template>
@@ -67,6 +79,7 @@ import { useTagStore } from '../../stores/tags.js'
import TagAutocomplete from './TagAutocomplete.vue'
import SuggestionsPanel from './SuggestionsPanel.vue'
import TagRenameDialog from './TagRenameDialog.vue'
import FandomSetDialog from './FandomSetDialog.vue'
const modal = useModalStore()
const store = useTagStore()
@@ -107,6 +120,18 @@ async function onRenamed() {
// Reflect the new name in the modal's current tag list without a full reload.
await modal.reloadTags()
}
const fandomDialog = ref(false)
const fandomTarget = ref(null)
function openSetFandom(tag) {
fandomTarget.value = tag
fandomDialog.value = true
}
async function onFandomUpdated() {
fandomDialog.value = false
// A fandom change can merge the tag away; reload to reflect the new state.
await modal.reloadTags()
}
</script>
<style scoped>
+75 -30
View File
@@ -3,12 +3,15 @@ import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useInflightToken } from '../composables/useInflightToken.js'
// Operator-confirmed 2026-05-30: fetch PAGE-sized chunks instead of one
// 50-item request so items render as each batch lands. Total initial
// count is unchanged (PAGE * INITIAL_BATCHES = 50). Infinite-scroll also
// pulls PAGE per trigger to keep appends progressive.
const PAGE = 5
const INITIAL_BATCHES = 10
// Initial paint is a SINGLE request (INITIAL_LIMIT). The old 10×serial-
// batch loop (2026-05-30) only staggered METADATA, which isn't the visual
// bottleneck — thumbnails load as independent `<img>` requests, and
// GalleryItem now reveals each tile on its own image `@load`. One fetch is
// far fewer round-trips and faster to first paint; the reveal-on-load is
// what makes appearance progressive. Infinite scroll pulls PAGE per trigger.
// Reworked 2026-06-04.
const PAGE = 25
const INITIAL_LIMIT = 50
export const useGalleryStore = defineStore('gallery', () => {
const api = useApi()
@@ -18,7 +21,14 @@ export const useGalleryStore = defineStore('gallery', () => {
const nextCursor = ref(null)
const loading = ref(false)
const error = ref(null)
const filter = ref({ tag_id: null, post_id: null })
const filter = ref({
tag_ids: [], artist_id: null, media_type: null,
sort: 'newest', post_id: null,
})
// Display names for the active filter chips — resolved by id on deep-link
// and pre-noted by the filter bar when a user picks from autocomplete.
const tagLabels = ref({}) // tagId -> name
const artistLabel = ref(null)
const timelineBuckets = ref([])
const timelineLoading = ref(false)
@@ -32,22 +42,16 @@ export const useGalleryStore = defineStore('gallery', () => {
images.value = []
dateGroups.value = []
nextCursor.value = null
// Sequentially fetch INITIAL_BATCHES chunks so items render as each
// batch lands rather than blocking on one big response. Stop early
// when the backend reports no more pages.
for (let i = 0; i < INITIAL_BATCHES; i++) {
if (i > 0 && nextCursor.value === null) break
await loadMore()
}
await loadMore(INITIAL_LIMIT)
}
async function loadMore() {
async function loadMore(limit = PAGE) {
if (loading.value) return
loading.value = true
error.value = null
const t = inflight.claim()
try {
const params = { limit: PAGE, ...activeFilterParam() }
const params = { limit, ...activeFilterParam() }
if (nextCursor.value) params.cursor = nextCursor.value
const body = await api.get('/api/gallery/scroll', { params })
if (!t.isCurrent()) return
@@ -89,23 +93,63 @@ export const useGalleryStore = defineStore('gallery', () => {
}
function activeFilterParam() {
if (filter.value.tag_id) return { tag_id: filter.value.tag_id }
// post_id is the exclusive post-detail view.
if (filter.value.post_id) return { post_id: filter.value.post_id }
return {}
const p = {}
if (filter.value.tag_ids.length) p.tag_id = filter.value.tag_ids.join(',')
if (filter.value.artist_id) p.artist_id = filter.value.artist_id
if (filter.value.media_type) p.media = filter.value.media_type
if (filter.value.sort && filter.value.sort !== 'newest') p.sort = filter.value.sort
return p
}
function setTagFilter(tagId) {
filter.value.tag_id = tagId
filter.value.post_id = null
loadInitial()
loadTimeline()
// URL is the source of truth for filters. GalleryView calls this on mount
// and on every route-query change; the filter bar mutates the URL
// (router.push) rather than the store directly, so deep-links, the back
// button, and bar actions all funnel through one path.
async function applyFilterFromQuery(q) {
filter.value = {
tag_ids: _parseIds(q.tag_id),
artist_id: _toId(q.artist_id),
media_type: ['image', 'video'].includes(q.media) ? q.media : null,
sort: q.sort === 'oldest' ? 'oldest' : 'newest',
post_id: _toId(q.post_id),
}
await loadInitial()
await loadTimeline()
_resolveLabels()
}
function setPostFilter(postId) {
filter.value.post_id = postId
filter.value.tag_id = null
loadInitial()
loadTimeline()
function _toId(v) {
const n = Number(v)
return Number.isInteger(n) && n > 0 ? n : null
}
function _parseIds(raw) {
if (!raw) return []
return String(raw).split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0)
}
// Pre-seed a label so a freshly-picked chip shows its name without a
// round-trip; the bar calls these before pushing the new URL.
function noteTagLabel(id, name) { tagLabels.value = { ...tagLabels.value, [id]: name } }
function noteArtistLabel(name) { artistLabel.value = name || null }
async function _resolveLabels() {
for (const id of filter.value.tag_ids) {
if (tagLabels.value[id]) continue
try {
const t = await api.get(`/api/tags/${id}`)
tagLabels.value = { ...tagLabels.value, [id]: t.name }
} catch { /* chip falls back to #id */ }
}
if (filter.value.artist_id && !artistLabel.value) {
// The filtered set is this artist — derive the chip label from a tile.
const hit = images.value.find(
(i) => i.artist && i.artist.id === filter.value.artist_id
)
artistLabel.value = hit?.artist?.name || null
}
if (!filter.value.artist_id) artistLabel.value = null
}
const hasMore = computed(() => nextCursor.value !== null)
@@ -113,8 +157,9 @@ export const useGalleryStore = defineStore('gallery', () => {
return {
images, dateGroups, hasMore, isEmpty, loading, error,
filter, timelineBuckets, timelineLoading,
loadInitial, loadMore, loadTimeline, jumpTo, setTagFilter, setPostFilter
filter, tagLabels, artistLabel, timelineBuckets, timelineLoading,
loadInitial, loadMore, loadTimeline, jumpTo,
applyFilterFromQuery, noteTagLabel, noteArtistLabel,
}
})
+14 -1
View File
@@ -49,8 +49,21 @@ export const useTagStore = defineStore('tags', () => {
return fandom
}
// Set / change / clear a character tag's fandom. fandomId null clears it.
// Throws ApiError (status 409, body.target) on a name collision in the
// target fandom; pass { merge: true } to resolve it by merging this tag
// into the existing character. Returns the updated/surviving tag.
async function setFandom(tagId, fandomId, { merge = false } = {}) {
const body = { fandom_id: fandomId ?? null }
if (merge) body.merge = true
return await api.patch(`/api/tags/${tagId}`, { body })
}
function kindOptions() { return KIND_OPTIONS }
function colorFor(kind) { return KIND_COLOR[kind] || 'on-surface' }
return { fandomCache, autocomplete, loadFandoms, createFandom, kindOptions, colorFor }
return {
fandomCache, autocomplete, loadFandoms, createFandom, setFandom,
kindOptions, colorFor
}
})
+7 -17
View File
@@ -12,6 +12,7 @@
<div class="fc-gallery-layout">
<div class="fc-gallery-layout__main">
<PostInfoHeader />
<GalleryFilterBar v-if="store.filter.post_id == null" />
<EmptyState v-if="store.isEmpty" />
<GalleryGrid v-else @open="openImage" />
</div>
@@ -28,6 +29,7 @@ import { useRoute } from 'vue-router'
import { useGalleryStore } from '../stores/gallery.js'
import { useModalStore } from '../stores/modal.js'
import GalleryGrid from '../components/gallery/GalleryGrid.vue'
import GalleryFilterBar from '../components/gallery/GalleryFilterBar.vue'
import TimelineSidebar from '../components/gallery/TimelineSidebar.vue'
import EmptyState from '../components/gallery/EmptyState.vue'
import PostInfoHeader from '../components/gallery/PostInfoHeader.vue'
@@ -39,25 +41,13 @@ const modal = useModalStore()
const sel = useGallerySelectionStore()
const route = useRoute()
onMounted(async () => {
const postId = parseInt(route.query.post_id, 10)
const tagId = parseInt(route.query.tag_id, 10)
if (!isNaN(postId)) store.setPostFilter(postId)
else if (!isNaN(tagId)) store.setTagFilter(tagId)
await store.loadInitial()
await store.loadTimeline()
})
// The URL query is the single source of truth for filters. Apply it on
// mount and on any query change (filter bar pushes, back button, deep-link).
onMounted(() => store.applyFilterFromQuery(route.query))
watch(() => route.query.tag_id, (q) => {
watch(() => route.query, (q) => {
sel.clear() // result set changed — selected ids are no longer valid
const tagId = parseInt(q, 10)
store.setTagFilter(isNaN(tagId) ? null : tagId)
})
watch(() => route.query.post_id, (q) => {
sel.clear() // result set changed — selected ids are no longer valid
const postId = parseInt(q, 10)
store.setPostFilter(isNaN(postId) ? null : postId)
store.applyFilterFromQuery(q)
})
function openImage(id) {
+22 -1
View File
@@ -8,7 +8,7 @@
placeholder="Search tags" clearable style="max-width: 320px;"
/>
<v-chip-group
v-model="kind" selected-class="text-accent" mandatory="false"
v-model="kind" selected-class="text-accent" :mandatory="false"
>
<v-chip v-for="k in KINDS" :key="k" :value="k" filter size="small">
{{ k }}
@@ -28,6 +28,7 @@
v-for="c in store.cards" :key="c.id" :card="c"
@open="openTag" @rename="onRename" @manage="onManage" @read="onRead"
@merge-with="onMergeWith" @delete="onDeleteTag"
@set-fandom="onSetFandom"
/>
</div>
@@ -85,6 +86,13 @@
: ''"
@confirm="onDeleteTagConfirm"
/>
<v-dialog v-model="fandomDialogOpen" max-width="460">
<FandomSetDialog
v-if="fandomTarget" :tag="fandomTarget"
@updated="onFandomUpdated" @cancel="fandomDialogOpen = false"
/>
</v-dialog>
</v-container>
</template>
@@ -98,6 +106,7 @@ import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
import TagCard from '../components/discovery/TagCard.vue'
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
import FandomSetDialog from '../components/modal/FandomSetDialog.vue'
// Must stay a subset of the backend TagKind enum (character, fandom,
// general, series, archive, post). 'fandom' is this model's
@@ -140,6 +149,18 @@ function openTag(tagId) {
router.push({ name: 'gallery', query: { tag_id: tagId } })
}
// Character fandom editing (dots-menu → FandomSetDialog).
const fandomDialogOpen = ref(false)
const fandomTarget = ref(null)
function onSetFandom(card) {
fandomTarget.value = card
fandomDialogOpen.value = true
}
function onFandomUpdated() {
fandomDialogOpen.value = false
store.reset() // reload so the card reflects the new fandom (or its removal)
}
function onManage(id) {
router.push({ name: 'series-manage', params: { tagId: id } })
}
@@ -0,0 +1,28 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import GalleryItem from '../../src/components/gallery/GalleryItem.vue'
import { freshPinia, mountComponent } from '../support/mountComponent.js'
describe('GalleryItem', () => {
const image = {
id: 7, sha256: 'a'.repeat(64), mime: 'image/jpeg',
width: 100, height: 100,
thumbnail_url: '/images/thumbs/aa/abc.jpg', artist: null,
}
it('reveals the thumbnail only once it has loaded, so tiles fade in individually', async () => {
const pinia = freshPinia()
const w = mountComponent(GalleryItem, { props: { image }, pinia })
const img = w.find('img')
expect(img.exists()).toBe(true)
// Pre-load: the tile must NOT be revealed, so it can fade in when its
// own thumbnail lands rather than pop together with its API batch.
expect(img.classes()).not.toContain('is-loaded')
await img.trigger('load')
expect(img.classes()).toContain('is-loaded')
})
})
+62 -24
View File
@@ -9,44 +9,82 @@ function stubFetch(handler) {
ok: status >= 200 && status < 300,
status,
statusText: String(status),
text: async () => (body == null ? '' : JSON.stringify(body))
text: async () => (body == null ? '' : JSON.stringify(body)),
}
})
}
const EMPTY = { images: [], date_groups: [], next_cursor: null }
describe('gallery store: tag/post filter exclusivity', () => {
describe('gallery store: composable filter', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.restoreAllMocks())
it('setPostFilter sets post_id and clears tag_id', () => {
it('applyFilterFromQuery parses the query and loadMore sends composable params', async () => {
const s = useGalleryStore()
stubFetch(() => ({ status: 200, body: EMPTY }))
s.setTagFilter(3)
expect(s.filter.tag_id).toBe(3)
s.setPostFilter(7)
expect(s.filter.post_id).toBe(7)
expect(s.filter.tag_id).toBe(null)
const urls = []
stubFetch((url) => {
urls.push(url)
if (url.includes('/api/tags/')) {
return { status: 200, body: { id: 1, name: 'X', kind: 'general' } }
}
return { status: 200, body: EMPTY }
})
await s.applyFilterFromQuery({ tag_id: '1,2', artist_id: '5', media: 'video', sort: 'oldest' })
expect(s.filter.tag_ids).toEqual([1, 2])
expect(s.filter.artist_id).toBe(5)
expect(s.filter.media_type).toBe('video')
expect(s.filter.sort).toBe('oldest')
const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll')))
expect(scroll).toContain('tag_id=1,2')
expect(scroll).toContain('artist_id=5')
expect(scroll).toContain('media=video')
expect(scroll).toContain('sort=oldest')
})
it('setTagFilter clears post_id', () => {
const s = useGalleryStore()
stubFetch(() => ({ status: 200, body: EMPTY }))
s.setPostFilter(7)
s.setTagFilter(3)
expect(s.filter.tag_id).toBe(3)
expect(s.filter.post_id).toBe(null)
})
it('loadMore sends exactly the active filter param', async () => {
it('omits sort=newest and sends no filter params when empty', async () => {
const s = useGalleryStore()
const urls = []
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
s.setPostFilter(7)
await s.loadMore()
const scrollCall = urls.filter(u => u.includes('/api/gallery/scroll')).pop()
expect(scrollCall).toContain('post_id=7')
expect(scrollCall).not.toContain('tag_id=')
await s.applyFilterFromQuery({})
const scroll = urls.find((u) => u.includes('/api/gallery/scroll'))
expect(scroll).not.toContain('sort=')
expect(scroll).not.toContain('tag_id=')
expect(scroll).not.toContain('media=')
})
it('treats post_id as the exclusive filter param', async () => {
const s = useGalleryStore()
const urls = []
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
await s.applyFilterFromQuery({ post_id: '7', tag_id: '1' })
expect(s.filter.post_id).toBe(7)
const scroll = urls.find((u) => u.includes('/api/gallery/scroll'))
expect(scroll).toContain('post_id=7')
expect(scroll).not.toContain('tag_id=')
})
it('noteTagLabel and noteArtistLabel pre-seed chip labels', () => {
const s = useGalleryStore()
s.noteTagLabel(9, 'Rukia')
expect(s.tagLabels[9]).toBe('Rukia')
s.noteArtistLabel('Kubo')
expect(s.artistLabel).toBe('Kubo')
})
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
const s = useGalleryStore()
const urls = []
// Non-null cursor: the old 10×serial-batch loop would have fired ten
// requests here. The single-fetch path must fire exactly one.
stubFetch((url) => {
urls.push(url)
return { status: 200, body: { images: [], date_groups: [], next_cursor: 'c1' } }
})
await s.loadInitial()
const scrollCalls = urls.filter((u) => u.includes('/api/gallery/scroll'))
expect(scrollCalls).toHaveLength(1)
expect(scrollCalls[0]).toContain('limit=50')
})
})
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useTagStore } from '../src/stores/tags.js'
function stubFetch(handler) {
globalThis.fetch = vi.fn(async (url, init) => {
const { status, body } = handler(url, init)
return {
ok: status >= 200 && status < 300,
status,
statusText: String(status),
text: async () => (body == null ? '' : JSON.stringify(body)),
}
})
}
describe('tags store: setFandom', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.restoreAllMocks())
it('PATCHes fandom_id, and null to clear', async () => {
const s = useTagStore()
const calls = []
stubFetch((url, init) => {
calls.push({ url, init })
return { status: 200, body: { id: 5, name: 'Ichigo', kind: 'character', fandom_id: 9 } }
})
const body = await s.setFandom(5, 9)
expect(body.fandom_id).toBe(9)
const last = calls.at(-1)
expect(last.url).toContain('/api/tags/5')
expect(last.init.method).toBe('PATCH')
expect(JSON.parse(last.init.body)).toEqual({ fandom_id: 9 })
await s.setFandom(5, null)
expect(JSON.parse(calls.at(-1).init.body)).toEqual({ fandom_id: null })
})
it('sends merge: true when requested', async () => {
const s = useTagStore()
const calls = []
stubFetch((url, init) => {
calls.push({ url, init })
return { status: 200, body: { id: 2 } }
})
await s.setFandom(7, 3, { merge: true })
expect(JSON.parse(calls.at(-1).init.body)).toEqual({ fandom_id: 3, merge: true })
})
it('throws ApiError carrying the 409 collision body', async () => {
const s = useTagStore()
stubFetch(() => ({
status: 409,
body: { error: 'exists', target: { id: 42, name: 'Renji' } },
}))
await expect(s.setFandom(1, 2)).rejects.toMatchObject({
status: 409,
body: { target: { id: 42 } },
})
})
})
+19
View File
@@ -17,6 +17,7 @@ async def _seed(db, count: int = 3):
origin="imported_filesystem", integrity_status="unknown",
)
r.created_at = base - timedelta(minutes=i)
r.effective_date = r.created_at # no post → tracks created_at (0035)
db.add(r)
await db.flush()
await db.commit()
@@ -58,3 +59,21 @@ async def test_jump_requires_year_month(client):
async def test_image_detail_404_when_missing(client):
resp = await client.get("/api/gallery/image/99999")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_scroll_sort_param_reverses(client, db):
await _seed(db, 3)
newest = await (await client.get("/api/gallery/scroll?limit=10&sort=newest")).get_json()
oldest = await (await client.get("/api/gallery/scroll?limit=10&sort=oldest")).get_json()
ids_new = [i["id"] for i in newest["images"]]
ids_old = [i["id"] for i in oldest["images"]]
assert ids_old == list(reversed(ids_new))
@pytest.mark.asyncio
async def test_scroll_media_param(client, db):
await _seed(db, 2) # only image/jpeg seeded
resp = await client.get("/api/gallery/scroll?limit=10&media=video")
assert resp.status_code == 200
assert (await resp.get_json())["images"] == []
+48 -2
View File
@@ -3,11 +3,57 @@ import pytest
pytestmark = pytest.mark.integration
async def _mk(client, name, kind):
r = await client.post("/api/tags", json={"name": name, "kind": kind})
async def _mk(client, name, kind, fandom_id=None):
body = {"name": name, "kind": kind}
if fandom_id is not None:
body["fandom_id"] = fandom_id
r = await client.post("/api/tags", json=body)
return (await r.get_json())["id"]
@pytest.mark.asyncio
async def test_get_tag_returns_shape_and_404(client):
tid = await _mk(client, "Lookup", "general")
resp = await client.get(f"/api/tags/{tid}")
assert resp.status_code == 200
body = await resp.get_json()
assert body["id"] == tid
assert body["name"] == "Lookup"
assert body["kind"] == "general"
resp = await client.get("/api/tags/99999999")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_patch_sets_and_clears_character_fandom(client):
fandom = await _mk(client, "Bleach", "fandom")
char = await _mk(client, "Ichigo", "character")
resp = await client.patch(f"/api/tags/{char}", json={"fandom_id": fandom})
assert resp.status_code == 200
assert (await resp.get_json())["fandom_id"] == fandom
resp = await client.patch(f"/api/tags/{char}", json={"fandom_id": None})
assert resp.status_code == 200
assert (await resp.get_json())["fandom_id"] is None
@pytest.mark.asyncio
async def test_patch_fandom_collision_then_merge(client):
f1 = await _mk(client, "Bleach", "fandom")
f2 = await _mk(client, "Naruto", "fandom")
c1 = await _mk(client, "Renji", "character", fandom_id=f1)
c2 = await _mk(client, "Renji", "character", fandom_id=f2)
# Moving c1 into f2 collides with c2 → rich 409 (same shape as rename).
resp = await client.patch(f"/api/tags/{c1}", json={"fandom_id": f2})
assert resp.status_code == 409
assert (await resp.get_json())["target"]["id"] == c2
# Confirm → merge c1 into c2, c2 survives.
resp = await client.patch(
f"/api/tags/{c1}", json={"fandom_id": f2, "merge": True}
)
assert resp.status_code == 200
assert (await resp.get_json())["id"] == c2
@pytest.mark.asyncio
async def test_rename_collision_returns_rich_409(client):
tgt = await _mk(client, "Canonical", "general")
+1
View File
@@ -14,6 +14,7 @@ async def _img(db, n):
origin="imported_filesystem", integrity_status="unknown",
)
rec.created_at = datetime.now(UTC) - timedelta(minutes=n)
rec.effective_date = rec.created_at # no post → tracks created_at (0035)
db.add(rec)
await db.flush()
return rec
+3 -2
View File
@@ -22,6 +22,7 @@ async def _img(db, n):
origin="imported_filesystem", integrity_status="unknown",
)
rec.created_at = base - timedelta(minutes=n)
rec.effective_date = rec.created_at # no primary post → tracks created_at (0035)
db.add(rec)
await db.flush()
return rec
@@ -80,10 +81,10 @@ async def test_scroll_artist_id_filter(db):
@pytest.mark.asyncio
async def test_mutually_exclusive_filters_raise(db):
async def test_post_id_excludes_other_filters(db):
svc = GalleryService(db)
with pytest.raises(ValueError):
await svc.scroll(cursor=None, limit=10, tag_id=1, post_id=2)
await svc.scroll(cursor=None, limit=10, tag_ids=[1], post_id=2)
@pytest.mark.asyncio
+57 -1
View File
@@ -32,6 +32,8 @@ async def _seed_images(db, count: int, sha_prefix: str = "0") -> list[ImageRecor
integrity_status="unknown",
)
r.created_at = base - timedelta(minutes=i)
# No post → effective_date == created_at (alembic 0035 denorm).
r.effective_date = r.created_at
db.add(r)
records.append(r)
await db.flush()
@@ -85,7 +87,7 @@ async def test_scroll_with_tag_filter(db):
)
svc = GalleryService(db)
page = await svc.scroll(cursor=None, limit=10, tag_id=tag.id)
page = await svc.scroll(cursor=None, limit=10, tag_ids=[tag.id])
assert len(page.images) == 1
assert page.images[0].id == images[0].id
@@ -166,6 +168,9 @@ async def _seed_image_with_post(
primary_post_id=post.id,
)
img.created_at = image_created_at
# Mirror the importer's denorm (alembic 0035): a linked post with a date
# sets effective_date to that date, else it falls back to created_at.
img.effective_date = post_date or image_created_at
db.add(img)
await db.flush()
return img, post
@@ -201,6 +206,7 @@ async def test_scroll_sorts_by_post_date_when_available(db):
origin="imported_filesystem", integrity_status="unknown",
)
img_c.created_at = base_import - timedelta(days=5)
img_c.effective_date = img_c.created_at # no post → tracks created_at
db.add(img_c)
await db.flush()
@@ -266,3 +272,53 @@ async def test_get_image_with_tags_includes_posted_at_when_present(db):
assert payload["posted_at"] is not None
# Image's own created_at is still surfaced separately.
assert payload["created_at"] != payload["posted_at"]
@pytest.mark.asyncio
async def test_scroll_multi_tag_and(db):
images = await _seed_images(db, 5)
a = Tag(name="aa", kind=TagKind.general)
b = Tag(name="bb", kind=TagKind.general)
db.add_all([a, b])
await db.flush()
# images[0] carries BOTH tags; images[1] carries only a.
await db.execute(image_tag.insert().values([
{"image_record_id": images[0].id, "tag_id": a.id, "source": "manual"},
{"image_record_id": images[0].id, "tag_id": b.id, "source": "manual"},
{"image_record_id": images[1].id, "tag_id": a.id, "source": "manual"},
]))
svc = GalleryService(db)
both = await svc.scroll(cursor=None, limit=10, tag_ids=[a.id, b.id])
assert [i.id for i in both.images] == [images[0].id] # AND: only the both-tagged
just_a = await svc.scroll(cursor=None, limit=10, tag_ids=[a.id])
assert {i.id for i in just_a.images} == {images[0].id, images[1].id}
@pytest.mark.asyncio
async def test_scroll_media_filter(db):
imgs = await _seed_images(db, 2) # image/jpeg
vid = ImageRecord(
path="/images/test/v.mp4", sha256="v" * 64, size_bytes=1,
mime="video/mp4", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
)
vid.created_at = _now()
vid.effective_date = vid.created_at
db.add(vid)
await db.flush()
svc = GalleryService(db)
vids = await svc.scroll(cursor=None, limit=10, media_type="video")
assert [i.id for i in vids.images] == [vid.id]
pics = await svc.scroll(cursor=None, limit=10, media_type="image")
assert {i.id for i in pics.images} == {i.id for i in imgs}
@pytest.mark.asyncio
async def test_scroll_sort_oldest_reverses_order(db):
images = await _seed_images(db, 4) # images[0] newest ... images[3] oldest
svc = GalleryService(db)
newest = await svc.scroll(cursor=None, limit=10)
oldest = await svc.scroll(cursor=None, limit=10, sort="oldest")
newest_ids = [i.id for i in newest.images]
assert newest_ids[0] == images[0].id
assert [i.id for i in oldest.images] == list(reversed(newest_ids))
+3
View File
@@ -94,6 +94,9 @@ def test_sidecar_creates_provenance(importer, import_layout):
prov = importer.session.execute(select(ImageProvenance)).scalar_one()
assert prov.image_record_id == rec.id and prov.post_id == post.id
assert rec.primary_post_id == post.id
# Denormalized gallery sort key (alembic 0035) tracks the primary post's
# publish date so /scroll orders off ix_image_record_effective_date.
assert rec.effective_date == post.post_date
def test_reimport_same_post_idempotent(importer, import_layout):
+66 -1
View File
@@ -1,7 +1,11 @@
import pytest
from backend.app.models import TagKind
from backend.app.services.tag_service import TagService, TagValidationError
from backend.app.services.tag_service import (
TagMergeConflict,
TagService,
TagValidationError,
)
pytestmark = pytest.mark.integration
@@ -101,3 +105,64 @@ async def test_autocomplete_empty_query_returns_nothing(db):
svc = TagService(db)
await svc.find_or_create("Foo", TagKind.artist)
assert await svc.autocomplete("") == []
@pytest.mark.asyncio
async def test_set_fandom_assigns_changes_and_clears(db):
svc = TagService(db)
f1 = await svc.find_or_create("Bleach", TagKind.fandom)
f2 = await svc.find_or_create("Naruto", TagKind.fandom)
char = await svc.find_or_create("Ichigo", TagKind.character)
assert char.fandom_id is None
assert (await svc.set_fandom(char.id, f1.id)).fandom_id == f1.id
assert (await svc.set_fandom(char.id, f2.id)).fandom_id == f2.id
assert (await svc.set_fandom(char.id, None)).fandom_id is None
@pytest.mark.asyncio
async def test_set_fandom_rejects_non_character(db):
svc = TagService(db)
fandom = await svc.find_or_create("Naruto", TagKind.fandom)
series = await svc.find_or_create("Arc 1", TagKind.series)
with pytest.raises(TagValidationError, match="character"):
await svc.set_fandom(series.id, fandom.id)
@pytest.mark.asyncio
async def test_set_fandom_rejects_non_fandom_reference(db):
svc = TagService(db)
general = await svc.find_or_create("misc", TagKind.general)
char = await svc.find_or_create("Rukia", TagKind.character)
with pytest.raises(TagValidationError, match="fandom"):
await svc.set_fandom(char.id, general.id)
@pytest.mark.asyncio
async def test_set_fandom_collision_raises_merge_conflict(db):
svc = TagService(db)
f1 = await svc.find_or_create("Bleach", TagKind.fandom)
f2 = await svc.find_or_create("Naruto", TagKind.fandom)
c1 = await svc.find_or_create("Renji", TagKind.character, fandom_id=f1.id)
c2 = await svc.find_or_create("Renji", TagKind.character, fandom_id=f2.id)
with pytest.raises(TagMergeConflict) as ei:
await svc.set_fandom(c1.id, f2.id) # collides with c2 in f2
assert ei.value.target_id == c2.id
@pytest.mark.asyncio
async def test_set_fandom_merge_resolves_collision(db):
from sqlalchemy import select
from backend.app.models import Tag
svc = TagService(db)
f1 = await svc.find_or_create("Bleach", TagKind.fandom)
f2 = await svc.find_or_create("Naruto", TagKind.fandom)
c1 = await svc.find_or_create("Renji", TagKind.character, fandom_id=f1.id)
c2 = await svc.find_or_create("Renji", TagKind.character, fandom_id=f2.id)
survivor = await svc.set_fandom(c1.id, f2.id, merge=True)
assert survivor.id == c2.id # merged INTO the existing character
gone = (
await db.execute(select(Tag).where(Tag.id == c1.id))
).scalar_one_or_none()
assert gone is None