Merge pull request 'Browse hub, series rename, full-prediction dropdown + a DRY pass (7 sweeps)' (#90) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 27s
CI / frontend-build (push) Successful in 44s
Build images / build-ml (push) Successful in 3m2s
CI / integration (push) Successful in 3m15s
Build images / build-web (push) Successful in 2m17s

This commit was merged in pull request #90.
This commit is contained in:
2026-06-10 00:24:01 -04:00
41 changed files with 698 additions and 495 deletions
+3 -23
View File
@@ -11,6 +11,7 @@ from ..services.bulk_tag_service import BulkTagService
from ..services.series_match_service import SeriesMatchService from ..services.series_match_service import SeriesMatchService
from ..services.series_service import SeriesError, SeriesService from ..services.series_service import SeriesError, SeriesService
from ..services.tag_directory_service import TagDirectoryService from ..services.tag_directory_service import TagDirectoryService
from ..services.tag_query import serialize_tag
from ..services.tag_service import ( from ..services.tag_service import (
TagMergeConflict, TagMergeConflict,
TagService, TagService,
@@ -72,17 +73,7 @@ async def autocomplete():
hits = await svc.autocomplete(q, kind=kind, limit=limit) hits = await svc.autocomplete(q, kind=kind, limit=limit)
return jsonify( return jsonify(
[ [{**serialize_tag(h), "image_count": h.image_count} for h in hits]
{
"id": h.id,
"name": h.name,
"kind": h.kind,
"fandom_id": h.fandom_id,
"fandom_name": h.fandom_name,
"image_count": h.image_count,
}
for h in hits
]
) )
@@ -165,18 +156,7 @@ async def list_tags_for_image(image_id: int):
async with get_session() as session: async with get_session() as session:
svc = TagService(session) svc = TagService(session)
tags = await svc.list_for_image(image_id) tags = await svc.list_for_image(image_id)
return jsonify( return jsonify([serialize_tag(t) for t in tags])
[
{
"id": t.id,
"name": t.name,
"kind": t.kind.value,
"fandom_id": t.fandom_id,
"fandom_name": t.fandom_name,
}
for t in tags
]
)
@tags_bp.route("/images/<int:image_id>/tags", methods=["POST"]) @tags_bp.route("/images/<int:image_id>/tags", methods=["POST"])
+16 -20
View File
@@ -10,7 +10,6 @@ from dataclasses import dataclass
from sqlalchemy import and_, case, func, or_, select from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import ( from ..models import (
@@ -24,7 +23,9 @@ from ..models import (
) )
from ..models.tag import image_tag from ..models.tag import image_tag
from ..utils.slug import slugify from ..utils.slug import slugify
from .gallery_service import decode_cursor, encode_cursor, thumbnail_url from .db_helpers import get_or_create
from .gallery_service import thumbnail_url
from .pagination import decode_cursor, encode_cursor
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -250,12 +251,10 @@ class ArtistService:
) )
async def find_or_create(self, name: str) -> tuple[Artist, bool]: async def find_or_create(self, name: str) -> tuple[Artist, bool]:
"""Return (artist, created). Slug-keyed; idempotent under races. """Return (artist, created). Slug-keyed; idempotent under races via the
shared race-safe db_helpers.get_or_create (savepoint + IntegrityError
Audit 2026-06-02: switched from session.rollback() to a recovery). A new artist also seeds an ArtistVisit so the directory's
begin_nested savepoint + IntegrityError recovery so a lost `+N new` badge starts at 0.
race doesn't unwind the calling request's surrounding work.
Mirrors importer._get_or_create.
""" """
cleaned = (name or "").strip() cleaned = (name or "").strip()
if not cleaned: if not cleaned:
@@ -263,12 +262,8 @@ class ArtistService:
slug = slugify(cleaned) slug = slugify(cleaned)
select_existing = select(Artist).where(Artist.slug == slug) select_existing = select(Artist).where(Artist.slug == slug)
existing = (await self.session.execute(select_existing)).scalar_one_or_none()
if existing is not None:
return existing, False
sp = await self.session.begin_nested() async def _create() -> Artist:
try:
artist = Artist(name=cleaned, slug=slug) artist = Artist(name=cleaned, slug=slug)
self.session.add(artist) self.session.add(artist)
await self.session.flush() await self.session.flush()
@@ -279,13 +274,14 @@ class ArtistService:
# count every image imported in the same session. # count every image imported in the same session.
self.session.add(ArtistVisit(artist_id=artist.id)) self.session.add(ArtistVisit(artist_id=artist.id))
await self.session.flush() await self.session.flush()
await sp.commit() return artist
except IntegrityError:
await sp.rollback() artist, created = await get_or_create(
existing = (await self.session.execute(select_existing)).scalar_one() self.session, select_existing, _create
return existing, False )
await self.session.commit() if created:
return artist, True await self.session.commit()
return artist, created
async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]: async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]:
cleaned = (prefix or "").strip() cleaned = (prefix or "").strip()
+52
View File
@@ -0,0 +1,52 @@
"""Shared DB-access helpers for the async services.
`get_or_create` centralizes the race-safe find-or-create dance — SELECT, then on
a miss a savepoint INSERT that recovers (NOT a full rollback) when a concurrent
worker inserted the same row first. It was hand-rolled identically in
ArtistService, TagService and ExtensionService; divergent copies are exactly how
the duplicate-row / race bugs in [[reference_scalar_one_or_none_duplicates]] crept
in, so it lives in one place now (DRY pattern sweep 2026-06-09).
Note: this is the ASYNC sibling of `Importer._get_or_create` (sync, used by the
filesystem-import path). The two can't share an implementation across the
sync/async boundary; the importer one stays as the lone sync consumer.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from sqlalchemy import Select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
async def get_or_create[T](
session: AsyncSession,
select_stmt: Select,
factory: Callable[[], Awaitable[T]],
) -> tuple[T, bool]:
"""Race-safe find-or-create. Returns ``(row, created)``.
Run ``select_stmt`` (scalar_one_or_none); if a row exists, return it with
``created=False``. Otherwise open a SAVEPOINT and ``await factory()`` — which
must add its row(s), flush, and return the primary row. On ``IntegrityError``
(a concurrent worker inserted the same row first) roll back the SAVEPOINT —
NOT the outer transaction, which would lose the caller's surrounding work —
and re-run ``select_stmt`` (scalar_one) to return the row the other worker
created. The caller owns the outer commit.
A UNIQUE/partial-unique constraint matching ``select_stmt``'s predicate is
required for the recovery to trip; without it a duplicate slips through.
"""
existing = (await session.execute(select_stmt)).scalar_one_or_none()
if existing is not None:
return existing, False
sp = await session.begin_nested()
try:
row = await factory()
await sp.commit()
return row, True
except IntegrityError:
await sp.rollback()
return (await session.execute(select_stmt)).scalar_one(), False
+23 -40
View File
@@ -11,11 +11,11 @@ from __future__ import annotations
import re import re
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Artist, Source from ..models import Artist, Source
from ..utils.slug import slugify from ..utils.slug import slugify
from .db_helpers import get_or_create
from .source_service import BACKFILL_MAX_CHUNKS from .source_service import BACKFILL_MAX_CHUNKS
@@ -169,24 +169,16 @@ class ExtensionService:
500 against uq_artist_slug. 500 against uq_artist_slug.
""" """
slug = slugify(raw_name) slug = slugify(raw_name)
existing = (await self.session.execute(
select(Artist).where(Artist.slug == slug) async def _create() -> Artist:
)).scalar_one_or_none()
if existing is not None:
return existing, False
sp = await self.session.begin_nested()
try:
artist = Artist(name=raw_name, slug=slug, is_subscription=True) artist = Artist(name=raw_name, slug=slug, is_subscription=True)
self.session.add(artist) self.session.add(artist)
await self.session.flush() await self.session.flush()
await sp.commit() return artist
return artist, True
except IntegrityError: return await get_or_create(
await sp.rollback() self.session, select(Artist).where(Artist.slug == slug), _create
recovered = (await self.session.execute( )
select(Artist).where(Artist.slug == slug)
)).scalar_one()
return recovered, False
async def _find_or_create_source( async def _find_or_create_source(
self, *, artist_id: int, platform: str, url: str, self, *, artist_id: int, platform: str, url: str,
@@ -194,17 +186,13 @@ class ExtensionService:
"""Race-safe — same pattern as _find_or_create_artist above. The """Race-safe — same pattern as _find_or_create_artist above. The
uq_source_artist_platform_url constraint catches the duplicate uq_source_artist_platform_url constraint catches the duplicate
insert; we roll the savepoint back and re-select.""" insert; we roll the savepoint back and re-select."""
existing = (await self.session.execute( select_existing = select(Source).where(
select(Source).where( Source.artist_id == artist_id,
Source.artist_id == artist_id, Source.platform == platform,
Source.platform == platform, Source.url == url,
Source.url == url, )
)
)).scalar_one_or_none() async def _create() -> Source:
if existing is not None:
return existing, False
sp = await self.session.begin_nested()
try:
# New subscription sources arm run-until-done backfill (plan #693) # New subscription sources arm run-until-done backfill (plan #693)
# so the first ticks walk the full history (otherwise gallery-dl's # so the first ticks walk the full history (otherwise gallery-dl's
# exit:20 short-circuits before the archive is built). Mirrors # exit:20 short-circuits before the archive is built). Mirrors
@@ -219,16 +207,11 @@ class ExtensionService:
) )
self.session.add(src) self.session.add(src)
await self.session.flush() await self.session.flush()
await sp.commit() return src
except IntegrityError:
await sp.rollback() src, created = await get_or_create(
recovered = (await self.session.execute( self.session, select_existing, _create
select(Source).where( )
Source.artist_id == artist_id, if created:
Source.platform == platform, await self.session.commit()
Source.url == url, return src, created
)
)).scalar_one()
return recovered, False
await self.session.commit()
return src, True
+6 -36
View File
@@ -14,7 +14,6 @@ Decoding rejects malformed cursors with a ValueError; the API layer
translates that to HTTP 400. translates that to HTTP 400.
""" """
import base64
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
@@ -24,8 +23,8 @@ from sqlalchemy.orm import aliased
from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag
from ..models.tag import image_tag from ..models.tag import image_tag
from .pagination import decode_cursor, encode_cursor
CURSOR_SEPARATOR = "|" from .tag_query import fandom_join_alias, serialize_tag, tag_columns
# Reserved `platform` filter value selecting images with NO platformed # Reserved `platform` filter value selecting images with NO platformed
# provenance (filesystem imports). Returned by facets() as a null-valued # provenance (filesystem imports). Returned by facets() as a null-valued
@@ -35,20 +34,6 @@ CURSOR_SEPARATOR = "|"
UNSOURCED_PLATFORM = "__unsourced__" UNSOURCED_PLATFORM = "__unsourced__"
def encode_cursor(effective_date: datetime, image_id: int) -> str:
raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_id}"
return base64.urlsafe_b64encode(raw.encode()).decode()
def decode_cursor(cursor: str) -> tuple[datetime, int]:
try:
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
ts_part, id_part = raw.split(CURSOR_SEPARATOR, 1)
return datetime.fromisoformat(ts_part), int(id_part)
except Exception as exc:
raise ValueError(f"invalid cursor: {cursor!r}") from exc
def _effective_date_col(): def _effective_date_col():
"""The materialized gallery sort key: image_record.effective_date """The materialized gallery sort key: image_record.effective_date
(alembic 0035) = COALESCE(primary post's post_date, created_at), (alembic 0035) = COALESCE(primary post's post_date, created_at),
@@ -559,16 +544,10 @@ class GalleryService:
if record is None: if record is None:
return None return None
# Self-join Tag to resolve a character's fandom NAME (not just id) so the # Self-join Tag to resolve a character's fandom NAME (not just id) so the
# modal chip can label it without an N+1 — mirrors list_for_image. # modal chip can label it without an N+1 (shared tag_query helpers).
fandom_alias = Tag.__table__.alias("fandom_lookup") fandom_alias = fandom_join_alias()
tag_stmt = ( tag_stmt = (
select( select(*tag_columns(fandom_alias))
Tag.id,
Tag.name,
Tag.kind,
Tag.fandom_id,
fandom_alias.c.name.label("fandom_name"),
)
.select_from( .select_from(
Tag.__table__ Tag.__table__
.join(image_tag, image_tag.c.tag_id == Tag.id) .join(image_tag, image_tag.c.tag_id == Tag.id)
@@ -615,16 +594,7 @@ class GalleryService:
{"id": artist.id, "name": artist.name, "slug": artist.slug} {"id": artist.id, "name": artist.name, "slug": artist.slug}
if artist is not None else None if artist is not None else None
), ),
"tags": [ "tags": [serialize_tag(t) for t in tags],
{
"id": t.id,
"name": t.name,
"kind": t.kind.value if hasattr(t.kind, "value") else t.kind,
"fandom_id": t.fandom_id,
"fandom_name": t.fandom_name,
}
for t in tags
],
"neighbors": neighbors, "neighbors": neighbors,
} }
+31
View File
@@ -0,0 +1,31 @@
"""Shared opaque keyset-pagination cursor.
A cursor is base64(``<iso8601_sort_key>|<row_id>``). The sort key is whatever
DESC-ordered timestamp a feed paginates on (gallery: effective_date; posts:
COALESCE(post_date, downloaded_at); artists: created_at). `decode_cursor` rejects
a malformed cursor with ValueError, which the API layer maps to HTTP 400.
This was hand-rolled identically in gallery_service and post_feed_service (with
artist_service importing gallery's copy). Two divergent copies of a cursor format
silently break pagination in whichever feed drifts, so it lives once here now
(DRY pattern sweep 2026-06-10).
"""
import base64
from datetime import datetime
CURSOR_SEPARATOR = "|"
def encode_cursor(sort_key: datetime, row_id: int) -> str:
raw = f"{sort_key.isoformat()}{CURSOR_SEPARATOR}{row_id}"
return base64.urlsafe_b64encode(raw.encode()).decode()
def decode_cursor(cursor: str) -> tuple[datetime, int]:
try:
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
ts_part, id_part = raw.split(CURSOR_SEPARATOR, 1)
return datetime.fromisoformat(ts_part), int(id_part)
except Exception as exc:
raise ValueError(f"invalid cursor: {cursor!r}") from exc
+4 -22
View File
@@ -1,9 +1,8 @@
"""FC-3e: cursor-paginated read service for the Posts stream. """FC-3e: cursor-paginated read service for the Posts stream.
Mirrors GalleryService.scroll's cursor encoding so the frontend pattern Uses the shared `pagination` cursor (base64 of "<iso8601_sort_key>|<id>") so
is identical: base64 of "<iso8601_sort_key>|<post_id>". Sort key is every feed paginates identically. Sort key here is COALESCE(Post.post_date,
COALESCE(Post.post_date, Post.downloaded_at) so posts without a Post.downloaded_at) so posts without a publish date sort by when we captured them.
publish date sort by when we captured them.
Pure read-surface; no writes. The service composes the post dict Pure read-surface; no writes. The service composes the post dict
(thumbnails from every image linked to the post — its own primary images (thumbnails from every image linked to the post — its own primary images
@@ -12,9 +11,6 @@ attachments from PostAttachment) so the API layer can jsonify directly.
""" """
from __future__ import annotations from __future__ import annotations
import base64
from datetime import datetime
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -28,26 +24,12 @@ from ..models import (
) )
from ..utils.text import html_to_plain, truncate_at_word from ..utils.text import html_to_plain, truncate_at_word
from .gallery_service import thumbnail_url from .gallery_service import thumbnail_url
from .pagination import decode_cursor, encode_cursor
CURSOR_SEPARATOR = "|"
DESCRIPTION_LIMIT = 280 DESCRIPTION_LIMIT = 280
THUMBNAIL_LIMIT = 6 THUMBNAIL_LIMIT = 6
def encode_cursor(sort_key: datetime, post_id: int) -> str:
raw = f"{sort_key.isoformat()}{CURSOR_SEPARATOR}{post_id}"
return base64.urlsafe_b64encode(raw.encode()).decode()
def decode_cursor(cursor: str) -> tuple[datetime, int]:
try:
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
ts_part, id_part = raw.split(CURSOR_SEPARATOR, 1)
return datetime.fromisoformat(ts_part), int(id_part)
except Exception as exc:
raise ValueError(f"invalid cursor: {cursor!r}") from exc
def _sort_key(): def _sort_key():
"""Postgres COALESCE expression used in ORDER BY and WHERE clauses.""" """Postgres COALESCE expression used in ORDER BY and WHERE clauses."""
return func.coalesce(Post.post_date, Post.downloaded_at) return func.coalesce(Post.post_date, Post.downloaded_at)
+44
View File
@@ -0,0 +1,44 @@
"""Shared tag-with-fandom query columns + serialization.
Resolving a character tag's fandom NAME via a Tag self-join (Tag.fandom_id -> the
fandom Tag) and serializing the canonical
``{id, name, kind, fandom_id, fandom_name}`` dict were hand-written in
TagService.autocomplete / .list_for_image and GalleryService.get_image_with_tags
(the last two added by the fandom-on-chip feature, 978f49a). One source now
(DRY pattern sweep 2026-06-10) so a new tag field is added in exactly one place.
TagDirectoryService selects the FULL Tag ORM plus an image-count aggregate (a
different select shape), so it keeps its own variant — not folded in here.
"""
from ..models import Tag
def fandom_join_alias():
"""A Tag self-join alias for resolving a tag's fandom name. Outerjoin it on
``Tag.fandom_id == <alias>.c.id`` and select via `tag_columns(alias)`."""
return Tag.__table__.alias("fandom_lookup")
def tag_columns(fandom_alias):
"""The canonical (id, name, kind, fandom_id, fandom_name) column set for a
tag select that outerjoins `fandom_alias` (from `fandom_join_alias()`)."""
return [
Tag.id,
Tag.name,
Tag.kind,
Tag.fandom_id,
fandom_alias.c.name.label("fandom_name"),
]
def serialize_tag(row) -> dict:
"""Serialize a row carrying .id/.name/.kind/.fandom_id/.fandom_name to the
canonical tag dict. `kind` may be a TagKind enum or a plain string."""
return {
"id": row.id,
"name": row.name,
"kind": row.kind.value if hasattr(row.kind, "value") else row.kind,
"fandom_id": row.fandom_id,
"fandom_name": row.fandom_name,
}
+10 -25
View File
@@ -7,12 +7,13 @@ from dataclasses import dataclass
from sqlalchemy import and_, case, exists, func, select, text, update from sqlalchemy import and_, case, exists, func, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Tag, TagKind, image_tag from ..models import Tag, TagKind, image_tag
from ..models.tag_allowlist import TagAllowlist from ..models.tag_allowlist import TagAllowlist
from ..models.tag_reference_embedding import TagReferenceEmbedding from ..models.tag_reference_embedding import TagReferenceEmbedding
from .db_helpers import get_or_create
from .tag_query import fandom_join_alias, tag_columns
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -130,20 +131,14 @@ class TagService:
.order_by(Tag.id) .order_by(Tag.id)
.limit(1) .limit(1)
) )
existing = (await self.session.execute(stmt)).scalar_one_or_none() async def _create() -> Tag:
if existing:
return existing
sp = await self.session.begin_nested()
try:
new_tag = Tag(name=name, kind=kind, fandom_id=fandom_id) new_tag = Tag(name=name, kind=kind, fandom_id=fandom_id)
self.session.add(new_tag) self.session.add(new_tag)
await self.session.flush() await self.session.flush()
await sp.commit()
return new_tag return new_tag
except IntegrityError:
await sp.rollback() tag, _created = await get_or_create(self.session, stmt, _create)
return (await self.session.execute(stmt)).scalar_one() return tag
async def autocomplete( async def autocomplete(
self, self,
@@ -165,7 +160,7 @@ class TagService:
else_=2, else_=2,
) )
fandom_alias = Tag.__table__.alias("fandom_lookup") fandom_alias = fandom_join_alias()
image_count = ( image_count = (
select(func.count(image_tag.c.image_record_id)) select(func.count(image_tag.c.image_record_id))
.where(image_tag.c.tag_id == Tag.id) .where(image_tag.c.tag_id == Tag.id)
@@ -175,11 +170,7 @@ class TagService:
stmt = ( stmt = (
select( select(
Tag.id, *tag_columns(fandom_alias),
Tag.name,
Tag.kind,
Tag.fandom_id,
fandom_alias.c.name.label("fandom_name"),
image_count.label("image_count"), image_count.label("image_count"),
) )
.select_from(Tag.__table__.outerjoin( .select_from(Tag.__table__.outerjoin(
@@ -230,15 +221,9 @@ class TagService:
NAME (not just fandom_id) via a self-join on Tag, so the UI can label a NAME (not just fandom_id) via a self-join on Tag, so the UI can label a
character chip with its fandom without an N+1 (mirrors the character chip with its fandom without an N+1 (mirrors the
autocomplete/directory resolution).""" autocomplete/directory resolution)."""
fandom_alias = Tag.__table__.alias("fandom_lookup") fandom_alias = fandom_join_alias()
stmt = ( stmt = (
select( select(*tag_columns(fandom_alias))
Tag.id,
Tag.name,
Tag.kind,
Tag.fandom_id,
fandom_alias.c.name.label("fandom_name"),
)
.select_from( .select_from(
Tag.__table__ Tag.__table__
.join(image_tag, image_tag.c.tag_id == Tag.id) .join(image_tag, image_tag.c.tag_id == Tag.id)
@@ -1,9 +1,6 @@
<template> <template>
<v-card class="fc-danger-zone mt-8" variant="outlined"> <v-card class="fc-danger-zone mt-8" variant="outlined">
<v-card-title class="d-flex align-center" style="gap: 10px;"> <CardHeading icon="mdi-alert-octagon" icon-color="error" title="Danger zone" />
<v-icon icon="mdi-alert-octagon" color="error" size="small" />
<span>Danger zone</span>
</v-card-title>
<v-card-text> <v-card-text>
<p class="text-body-2 fc-muted mb-4"> <p class="text-body-2 fc-muted mb-4">
Cascade-delete this artist and every image, source, post, and Cascade-delete this artist and every image, source, post, and
@@ -37,6 +34,7 @@ import { useRouter } from 'vue-router'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue' import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
import { useAdminStore } from '../../stores/admin.js' import { useAdminStore } from '../../stores/admin.js'
import CardHeading from '../common/CardHeading.vue'
const props = defineProps({ const props = defineProps({
slug: { type: String, required: true }, slug: { type: String, required: true },
@@ -91,5 +89,4 @@ async function onConfirm(token) {
border-color: rgb(var(--v-theme-error)); border-color: rgb(var(--v-theme-error));
border-radius: 8px; border-radius: 8px;
} }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
</style> </style>
@@ -1,9 +1,6 @@
<template> <template>
<v-card class="fc-clean-card"> <v-card class="fc-clean-card">
<v-card-title class="d-flex align-center" style="gap: 10px;"> <CardHeading icon="mdi-image-size-select-small" title="Minimum dimensions" />
<v-icon icon="mdi-image-size-select-small" size="small" />
<span>Minimum dimensions</span>
</v-card-title>
<v-card-text> <v-card-text>
<p class="fc-muted text-body-2 mb-3"> <p class="fc-muted text-body-2 mb-3">
Find and delete images smaller than the threshold. Mirrors the Find and delete images smaller than the threshold. Mirrors the
@@ -67,6 +64,7 @@ import { onMounted, ref } from 'vue'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue' import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
import { useCleanupStore } from '../../stores/cleanup.js' import { useCleanupStore } from '../../stores/cleanup.js'
import CardHeading from '../common/CardHeading.vue'
// Backend's preview response hands the full Tier-C confirm token back // Backend's preview response hands the full Tier-C confirm token back
// as `confirm_token` (e.g. `delete-min-dim-1a2b3c4d`); passed straight // as `confirm_token` (e.g. `delete-min-dim-1a2b3c4d`); passed straight
@@ -121,5 +119,4 @@ async function onConfirmedDelete(token) {
<style scoped> <style scoped>
.fc-clean-card { border-radius: 8px; } .fc-clean-card { border-radius: 8px; }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
</style> </style>
@@ -1,9 +1,6 @@
<template> <template>
<v-card class="fc-clean-card"> <v-card class="fc-clean-card">
<v-card-title class="d-flex align-center" style="gap: 10px;"> <CardHeading icon="mdi-palette-swatch" title="Single-color audit" />
<v-icon icon="mdi-palette-swatch" size="small" />
<span>Single-color audit</span>
</v-card-title>
<v-card-text> <v-card-text>
<p class="fc-muted text-body-2 mb-3"> <p class="fc-muted text-body-2 mb-3">
Scan library for images dominated by one color within the Scan library for images dominated by one color within the
@@ -97,6 +94,7 @@ import { toast } from '../../utils/toast.js'
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue' import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
import CardHeading from '../common/CardHeading.vue'
import { useCleanupStore } from '../../stores/cleanup.js' import { useCleanupStore } from '../../stores/cleanup.js'
const store = useCleanupStore() const store = useCleanupStore()
@@ -190,5 +188,4 @@ async function onConfirmedApply(token) {
<style scoped> <style scoped>
.fc-clean-card { border-radius: 8px; } .fc-clean-card { border-radius: 8px; }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
</style> </style>
@@ -1,9 +1,6 @@
<template> <template>
<v-card class="fc-clean-card"> <v-card class="fc-clean-card">
<v-card-title class="d-flex align-center" style="gap: 10px;"> <CardHeading icon="mdi-checkerboard" title="Transparency audit" />
<v-icon icon="mdi-checkerboard" size="small" />
<span>Transparency audit</span>
</v-card-title>
<v-card-text> <v-card-text>
<p class="fc-muted text-body-2 mb-3"> <p class="fc-muted text-body-2 mb-3">
Scan library for images whose transparent-pixel fraction exceeds Scan library for images whose transparent-pixel fraction exceeds
@@ -84,6 +81,7 @@ import { toast } from '../../utils/toast.js'
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue' import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
import CardHeading from '../common/CardHeading.vue'
import { useCleanupStore } from '../../stores/cleanup.js' import { useCleanupStore } from '../../stores/cleanup.js'
const store = useCleanupStore() const store = useCleanupStore()
@@ -173,5 +171,4 @@ async function onConfirmedApply(token) {
<style scoped> <style scoped>
.fc-clean-card { border-radius: 8px; } .fc-clean-card { border-radius: 8px; }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
</style> </style>
@@ -0,0 +1,25 @@
<template>
<!-- Canonical card/dialog heading: an optional leading icon + the title, with
a default slot for trailing content (a v-spacer + actions, or an inline
status chip). The icon+title v-card-title row was hand-rolled identically
in 14 cards/dialogs (DRY pattern sweep 2026-06-09). Plain text-only
v-card-titles are NOT this pattern and keep using <v-card-title> directly. -->
<v-card-title class="d-flex align-center fc-card-heading">
<v-icon v-if="icon" :icon="icon" :color="iconColor || undefined" size="small" />
<span>{{ title }}</span>
<slot />
</v-card-title>
</template>
<script setup>
defineProps({
icon: { type: String, default: '' },
title: { type: String, default: '' },
// e.g. 'error' for danger/error headings; '' keeps the default text color.
iconColor: { type: String, default: '' },
})
</script>
<style scoped>
.fc-card-heading { gap: 10px; }
</style>
@@ -2,14 +2,15 @@
<v-dialog :model-value="modelValue" max-width="900" <v-dialog :model-value="modelValue" max-width="900"
@update:model-value="$emit('update:modelValue', $event)"> @update:model-value="$emit('update:modelValue', $event)">
<v-card> <v-card>
<v-card-title class="d-flex align-center" style="gap: 12px;"> <CardHeading
<v-icon icon="mdi-alert-circle-outline" color="error" /> icon="mdi-alert-circle-outline" icon-color="error"
<span>{{ displayTitle }}</span> :title="displayTitle"
>
<v-spacer /> <v-spacer />
<v-btn icon variant="text" size="small" @click="close"> <v-btn icon variant="text" size="small" @click="close">
<v-icon>mdi-close</v-icon> <v-icon>mdi-close</v-icon>
</v-btn> </v-btn>
</v-card-title> </CardHeading>
<v-card-text> <v-card-text>
<dl v-if="contextRows.length" class="fc-err-context"> <dl v-if="contextRows.length" class="fc-err-context">
<template v-for="(row, idx) in contextRows" :key="idx"> <template v-for="(row, idx) in contextRows" :key="idx">
@@ -37,6 +38,7 @@ import { toast } from '../../utils/toast.js'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { copyText } from '../../utils/clipboard.js' import { copyText } from '../../utils/clipboard.js'
import CardHeading from './CardHeading.vue'
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
@@ -0,0 +1,50 @@
<template>
<!-- Canonical kebab (overflow ) menu the single primitive every kebab in
the app uses (DRY pattern-consistency sweep 2026-06-09). It bakes in the
modal-safe activator strategy that the image-modal kebabs needed (#711):
a manual v-model toggled by @click.stop, `activator="parent"` for
positioning only, `:open-on-click="false"`, and a z-index above the modal
(2000) so a kebab works identically inside a teleported modal and on a
plain page. Menu items go in the default slot; close-on-content-click
(Vuetify default) closes the menu when one is chosen. -->
<span class="fc-kebab">
<v-btn
:icon="icon"
:size="size"
:variant="variant"
:aria-label="label"
@click.stop="open = !open"
/>
<v-menu
v-model="open"
activator="parent"
:open-on-click="false"
:location="location"
:z-index="2400"
>
<v-list density="compact" :min-width="minWidth">
<slot />
</v-list>
</v-menu>
</span>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
icon: { type: String, default: 'mdi-dots-vertical' },
size: { type: String, default: 'x-small' },
variant: { type: String, default: 'text' },
location: { type: String, default: 'bottom end' },
// aria-label for the trigger (every kebab should name what it acts on).
label: { type: String, default: 'More actions' },
minWidth: { type: [String, Number], default: undefined },
})
const open = ref(false)
</script>
<style scoped>
.fc-kebab { display: inline-flex; align-items: center; }
</style>
@@ -0,0 +1,32 @@
<template>
<!-- Scrollable grid of monospace name chips the preview "here's a sample of
what will change" surface shared by the maintenance cards (DRY pattern
sweep 2026-06-09). Pass `names` for the common case; use the default slot
to render custom chips (style them `.fc-name` and they inherit the look
via :slotted). The caller keeps its own v-if for the empty case. -->
<div class="fc-name-grid">
<slot>
<span v-for="(n, i) in names" :key="i" class="fc-name">{{ n }}</span>
</slot>
</div>
</template>
<script setup>
defineProps({
names: { type: Array, default: () => [] },
})
</script>
<style scoped>
.fc-name-grid {
display: flex; flex-wrap: wrap; gap: 4px 8px;
max-height: 200px; overflow-y: auto;
padding: 8px; border-radius: 4px;
background: rgb(var(--v-theme-surface-light));
}
.fc-name, :slotted(.fc-name) {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: rgb(var(--v-theme-on-surface-variant));
}
</style>
+20 -28
View File
@@ -46,34 +46,25 @@
<v-chip size="x-small" label>{{ card.kind }}</v-chip> <v-chip size="x-small" label>{{ card.kind }}</v-chip>
<div class="fc-tagcard__meta-right"> <div class="fc-tagcard__meta-right">
<span class="fc-tagcard__count">{{ card.image_count }}</span> <span class="fc-tagcard__count">{{ card.image_count }}</span>
<v-menu> <KebabMenu class="fc-tagcard__menu" :label="`Actions for ${card.name}`">
<template #activator="{ props: act }"> <v-list-item
<v-btn v-if="card.kind === 'character'"
class="fc-tagcard__menu" title="Set fandom…"
icon="mdi-dots-vertical" size="x-small" variant="text" prepend-icon="mdi-book-open-page-variant"
v-bind="act" @click.stop @click="$emit('set-fandom', card)"
/> />
</template> <v-list-item
<v-list density="compact"> title="Merge with…"
<v-list-item prepend-icon="mdi-call-merge"
v-if="card.kind === 'character'" @click="$emit('merge-with', card)"
title="Set fandom…" />
prepend-icon="mdi-book-open-page-variant" <v-list-item
@click="$emit('set-fandom', card)" title="Delete tag"
/> prepend-icon="mdi-delete"
<v-list-item base-color="error"
title="Merge with…" @click="$emit('delete', card)"
prepend-icon="mdi-call-merge" />
@click="$emit('merge-with', card)" </KebabMenu>
/>
<v-list-item
title="Delete tag"
prepend-icon="mdi-delete"
base-color="error"
@click="$emit('delete', card)"
/>
</v-list>
</v-menu>
</div> </div>
</div> </div>
</v-card-text> </v-card-text>
@@ -82,6 +73,7 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import KebabMenu from '../common/KebabMenu.vue'
const props = defineProps({ card: { type: Object, required: true } }) const props = defineProps({ card: { type: Object, required: true } })
const emit = defineEmits([ const emit = defineEmits([
@@ -19,46 +19,29 @@
> >
Accept Accept
</v-btn> </v-btn>
<!-- Operator-flagged 2026-06-04: the kebab still wasn't opening. The <!-- Modal-safe kebab is baked into KebabMenu (this row lives in the
prior `#activator` + `v-bind="props"` path never toggled the menu teleported image modal #711). -->
inside this teleported modal, while v-model-driven overlays (the <KebabMenu
dialogs in this modal) work fine. So drive the menu explicitly: class="fc-suggestion__menu" size="small" variant="outlined"
the button toggles `menuOpen` with @click.stop (also shields any :label="`More actions for ${suggestion.display_name}`"
parent), and `activator="parent"` anchors the menu for positioning >
only — `:open-on-click="false"` keeps Vuetify's activator-click out <v-list-item @click="$emit('alias', suggestion)">
of it, so there's a single, reliable opener. --> <v-list-item-title>Treat as alias for</v-list-item-title>
<span class="fc-suggestion__menu-wrap"> </v-list-item>
<v-btn <v-list-item @click="$emit('dismiss', suggestion)">
class="fc-suggestion__menu" <v-list-item-title>Dismiss for this image</v-list-item-title>
icon="mdi-dots-vertical" size="small" </v-list-item>
variant="outlined" density="compact" </KebabMenu>
:aria-label="`More actions for ${suggestion.display_name}`"
@click.stop="menuOpen = !menuOpen"
/>
<v-menu
v-model="menuOpen" activator="parent" :open-on-click="false"
:z-index="2400"
>
<v-list density="compact">
<v-list-item @click="$emit('alias', suggestion)">
<v-list-item-title>Treat as alias for…</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('dismiss', suggestion)">
<v-list-item-title>Dismiss for this image</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed } from 'vue'
import KebabMenu from '../common/KebabMenu.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>
@@ -102,11 +85,6 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
.fc-suggestion__accept :deep(.v-btn__content) { .fc-suggestion__accept :deep(.v-btn__content) {
font-size: 12px; letter-spacing: 0.02em; font-size: 12px; letter-spacing: 0.02em;
} }
.fc-suggestion__menu-wrap {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
}
.fc-suggestion__menu { .fc-suggestion__menu {
flex: 0 0 auto; flex: 0 0 auto;
} }
+12 -31
View File
@@ -1,9 +1,4 @@
<template> <template>
<!-- One tag chip + its kebab. The kebab uses the SAME explicit-menu pattern
as SuggestionItem (activator="parent" + :open-on-click="false" + a manual
v-model), because the #activator / v-bind="props" pattern never toggles a
v-menu inside the teleported ImageViewer modal (#711, re-fixed 2026-06-07
after the first attempt used the broken pattern). -->
<span class="fc-tag-chip"> <span class="fc-tag-chip">
<v-chip <v-chip
size="small" closable size="small" closable
@@ -16,41 +11,28 @@
:title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'" :title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'"
><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></span> ><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></span>
</v-chip> </v-chip>
<span class="fc-tag-chip__menu-wrap"> <!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the
<v-btn teleported image modal #711). -->
class="fc-tag-chip__kebab" <KebabMenu class="fc-tag-chip__kebab" :label="`More actions for ${tag.name}`">
icon="mdi-dots-vertical" size="x-small" <v-list-item @click="$emit('rename', tag)">
variant="text" density="comfortable" <v-list-item-title>Rename</v-list-item-title>
:aria-label="`More actions for ${tag.name}`" </v-list-item>
@click.stop="menuOpen = !menuOpen" <v-list-item v-if="tag.kind === 'character'" @click="$emit('set-fandom', tag)">
/> <v-list-item-title>Set fandom</v-list-item-title>
<!-- :z-index above the modal's 2000 — the teleported menu otherwise lands </v-list-item>
BEHIND .fc-viewer and gets blurred by its backdrop-filter (operator </KebabMenu>
saw it ghosted behind the sidebar, 2026-06-07). THIS is what made the
kebab look dead the whole time: it opened, just underneath. -->
<v-menu v-model="menuOpen" activator="parent" :open-on-click="false" :z-index="2400">
<v-list density="compact">
<v-list-item @click="$emit('rename', tag)">
<v-list-item-title>Rename…</v-list-item-title>
</v-list-item>
<v-list-item v-if="tag.kind === 'character'" @click="$emit('set-fandom', tag)">
<v-list-item-title>Set fandom…</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
</span> </span>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed } from 'vue'
import { useTagStore } from '../../stores/tags.js' import { useTagStore } from '../../stores/tags.js'
import KebabMenu from '../common/KebabMenu.vue'
const props = defineProps({ tag: { type: Object, required: true } }) const props = defineProps({ tag: { type: Object, required: true } })
defineEmits(['remove', 'rename', 'set-fandom']) defineEmits(['remove', 'rename', 'set-fandom'])
const store = useTagStore() const store = useTagStore()
const menuOpen = ref(false)
// Show a character's fandom inline (truncated). Falls back to a bare arrow when // Show a character's fandom inline (truncated). Falls back to a bare arrow when
// only fandom_id is known but the name wasn't resolved (older payloads). // only fandom_id is known but the name wasn't resolved (older payloads).
@@ -71,7 +53,6 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
<style scoped> <style scoped>
.fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; } .fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; }
.fc-tag-chip__menu-wrap { display: inline-flex; align-items: center; }
.fc-tag-chip__kebab { opacity: 0.7; } .fc-tag-chip__kebab { opacity: 0.7; }
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; } .fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; } .fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
@@ -1,9 +1,6 @@
<template> <template>
<v-card class="fc-backup-card"> <v-card class="fc-backup-card">
<v-card-title class="d-flex align-center" style="gap: 10px;"> <CardHeading icon="mdi-database-export" title="Backups" />
<v-icon icon="mdi-database-export" size="small" />
<span>Backups</span>
</v-card-title>
<v-card-text> <v-card-text>
<p class="fc-muted text-body-2 mb-4"> <p class="fc-muted text-body-2 mb-4">
pg_dump for the database (fast, nightly-schedulable); pg_dump for the database (fast, nightly-schedulable);
@@ -101,6 +98,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useBackupStore } from '../../stores/backup.js' import { useBackupStore } from '../../stores/backup.js'
import BackupConfirmModal from '../modal/DestructiveConfirmModal.vue' import BackupConfirmModal from '../modal/DestructiveConfirmModal.vue'
import CardHeading from '../common/CardHeading.vue'
import BackupRunsTable from './BackupRunsTable.vue' import BackupRunsTable from './BackupRunsTable.vue'
const store = useBackupStore() const store = useBackupStore()
@@ -192,7 +190,6 @@ async function onConfirmSubmit(token) {
<style scoped> <style scoped>
.fc-backup-card { border-radius: 8px; } .fc-backup-card { border-radius: 8px; }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-section-title { .fc-section-title {
font-family: 'Fraunces', Georgia, serif; font-family: 'Fraunces', Georgia, serif;
font-size: 16px; font-weight: 500; font-size: 16px; font-weight: 500;
@@ -30,25 +30,20 @@
<span v-else class="fc-muted"></span> <span v-else class="fc-muted"></span>
</td> </td>
<td class="text-right"> <td class="text-right">
<v-menu> <KebabMenu :label="`Actions for backup ${r.id}`">
<template #activator="{ props: act }"> <v-list-item
<v-btn icon="mdi-dots-vertical" size="x-small" variant="text" v-bind="act" /> :title="r.tag ? 'Untag' : 'Tag…'"
</template> @click="$emit('tag', r)"
<v-list density="compact"> />
<v-list-item <v-list-item
:title="r.tag ? 'Untag' : 'Tag…'" title="Restore…" :disabled="r.status !== 'ok'"
@click="$emit('tag', r)" @click="$emit('restore', r)"
/> />
<v-list-item <v-list-item
title="Restore…" :disabled="r.status !== 'ok'" title="Delete…"
@click="$emit('restore', r)" @click="$emit('delete', r)"
/> />
<v-list-item </KebabMenu>
title="Delete…"
@click="$emit('delete', r)"
/>
</v-list>
</v-menu>
</td> </td>
</tr> </tr>
<tr v-if="!runs.length"> <tr v-if="!runs.length">
@@ -60,6 +55,7 @@
<script setup> <script setup>
import { formatRelative as fmtRelative } from '../../utils/date.js' import { formatRelative as fmtRelative } from '../../utils/date.js'
import KebabMenu from '../common/KebabMenu.vue'
defineProps({ runs: { type: Array, default: () => [] } }) defineProps({ runs: { type: Array, default: () => [] } })
defineEmits(['restore', 'delete', 'tag']) defineEmits(['restore', 'delete', 'tag'])
@@ -101,5 +97,4 @@ function formatRelative(iso) {
<style scoped> <style scoped>
.fc-backup-table { background: transparent; } .fc-backup-table { background: transparent; }
.fc-tabular { font-variant-numeric: tabular-nums; } .fc-tabular { font-variant-numeric: tabular-nums; }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
</style> </style>
@@ -1,12 +1,10 @@
<template> <template>
<v-card class="fc-ext-card"> <v-card class="fc-ext-card">
<v-card-title class="d-flex align-center" style="gap: 10px;"> <CardHeading icon="mdi-puzzle" title="Browser extension">
<v-icon icon="mdi-puzzle" size="small" />
<span>Browser extension</span>
<span v-if="manifest?.installed" class="text-caption fc-muted"> <span v-if="manifest?.installed" class="text-caption fc-muted">
· Firefox · v{{ manifest.version }} · Firefox · v{{ manifest.version }}
</span> </span>
</v-card-title> </CardHeading>
<v-card-text> <v-card-text>
<p class="fc-muted text-body-2"> <p class="fc-muted text-body-2">
@@ -112,6 +110,7 @@ import { toast } from '../../utils/toast.js'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useApi } from '../../composables/useApi.js' import { useApi } from '../../composables/useApi.js'
import { copyText } from '../../utils/clipboard.js' import { copyText } from '../../utils/clipboard.js'
import CardHeading from '../common/CardHeading.vue'
const api = useApi() const api = useApi()
@@ -187,7 +186,4 @@ async function copy(text, label) {
.fc-ext-install { .fc-ext-install {
display: flex; flex-wrap: wrap; gap: 8px; display: flex; flex-wrap: wrap; gap: 8px;
} }
.fc-muted {
color: rgb(var(--v-theme-on-surface-variant));
}
</style> </style>
@@ -1,7 +1,6 @@
<template> <template>
<v-card> <v-card>
<v-card-title class="d-flex align-center" style="gap: 12px;"> <CardHeading title="Recent import tasks">
<span>Recent import tasks</span>
<v-spacer /> <v-spacer />
<v-select <v-select
v-model="statusFilter" :items="statusOptions" density="compact" v-model="statusFilter" :items="statusOptions" density="compact"
@@ -29,7 +28,7 @@
> >
Clear completed Clear completed
</v-btn> </v-btn>
</v-card-title> </CardHeading>
<v-data-table-virtual <v-data-table-virtual
:headers="headers" :items="store.tasks" :loading="store.tasksLoading" :headers="headers" :items="store.tasks" :loading="store.tasksLoading"
height="480" density="compact" no-data-text="No tasks yet trigger a scan above." height="480" density="compact" no-data-text="No tasks yet trigger a scan above."
@@ -129,6 +128,7 @@ import { toast } from '../../utils/toast.js'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useImportStore } from '../../stores/import.js' import { useImportStore } from '../../stores/import.js'
import ErrorDetailModal from '../common/ErrorDetailModal.vue' import ErrorDetailModal from '../common/ErrorDetailModal.vue'
import CardHeading from '../common/CardHeading.vue'
const store = useImportStore() const store = useImportStore()
const statusFilter = ref(null) const statusFilter = ref(null)
@@ -1,9 +1,6 @@
<template> <template>
<v-card class="fc-post-maint"> <v-card class="fc-post-maint">
<v-card-title class="d-flex align-center" style="gap: 10px;"> <CardHeading icon="mdi-post-outline" title="Post maintenance" />
<v-icon icon="mdi-post-outline" size="small" />
<span>Post maintenance</span>
</v-card-title>
<v-card-text> <v-card-text>
<p class="fc-muted text-body-2 mb-4"> <p class="fc-muted text-body-2 mb-4">
Remove <strong>bare posts</strong> post rows with no images (neither Remove <strong>bare posts</strong> post rows with no images (neither
@@ -36,11 +33,10 @@
Nothing to clean up. Nothing to clean up.
</span> </span>
</p> </p>
<div v-if="preview.sample_names?.length" class="fc-name-grid mb-3"> <SampleNameGrid
<span v-for="(n, i) in preview.sample_names" :key="i" class="fc-name"> v-if="preview.sample_names?.length"
{{ n }} :names="preview.sample_names" class="mb-3"
</span> />
</div>
<v-btn <v-btn
color="error" variant="flat" rounded="pill" color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-sweep" prepend-icon="mdi-delete-sweep"
@@ -61,6 +57,8 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useAdminStore } from '../../stores/admin.js' import { useAdminStore } from '../../stores/admin.js'
import SampleNameGrid from '../common/SampleNameGrid.vue'
import CardHeading from '../common/CardHeading.vue'
const store = useAdminStore() const store = useAdminStore()
const preview = ref(null) const preview = ref(null)
@@ -94,16 +92,4 @@ async function onCommit() {
<style scoped> <style scoped>
.fc-post-maint { border-radius: 8px; } .fc-post-maint { border-radius: 8px; }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-name-grid {
display: flex; flex-wrap: wrap; gap: 4px 8px;
max-height: 200px; overflow-y: auto;
padding: 8px; border-radius: 4px;
background: rgb(var(--v-theme-surface-light));
}
.fc-name {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: rgb(var(--v-theme-on-surface-variant));
}
</style> </style>
@@ -101,7 +101,6 @@ function recentErr(name) { return recentByQueue.value[name]?.err || 0 }
.fc-ok { color: rgb(var(--v-theme-success, 76 175 80)); } .fc-ok { color: rgb(var(--v-theme-success, 76 175 80)); }
.fc-err { color: rgb(var(--v-theme-error, 220 80 80)); } .fc-err { color: rgb(var(--v-theme-error, 220 80 80)); }
.fc-warn { color: rgb(var(--v-theme-warning, 255 179 0)); font-weight: 500; } .fc-warn { color: rgb(var(--v-theme-warning, 255 179 0)); font-weight: 500; }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-sep { .fc-sep {
margin: 0 4px; margin: 0 4px;
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
@@ -1,8 +1,6 @@
<template> <template>
<v-card class="fc-activity-summary"> <v-card class="fc-activity-summary">
<v-card-title class="d-flex align-center" style="gap: 10px;"> <CardHeading icon="mdi-pulse" title="System activity (last min)">
<v-icon icon="mdi-pulse" size="small" />
<span>System activity (last min)</span>
<v-spacer /> <v-spacer />
<v-btn <v-btn
variant="text" size="small" rounded="pill" variant="text" size="small" rounded="pill"
@@ -11,7 +9,7 @@
View activity View activity
<v-icon end size="small">mdi-arrow-right</v-icon> <v-icon end size="small">mdi-arrow-right</v-icon>
</v-btn> </v-btn>
</v-card-title> </CardHeading>
<v-card-text> <v-card-text>
<v-alert <v-alert
@@ -36,6 +34,7 @@ import { onMounted, onUnmounted } from 'vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js' import { useSystemActivityStore } from '../../stores/systemActivity.js'
import QueuesTable from './QueuesTable.vue' import QueuesTable from './QueuesTable.vue'
import CardHeading from '../common/CardHeading.vue'
defineEmits(['open-activity']) defineEmits(['open-activity'])
@@ -2,14 +2,12 @@
<div class="fc-activity"> <div class="fc-activity">
<!-- Queues + workers pane --> <!-- Queues + workers pane -->
<v-card class="mb-4"> <v-card class="mb-4">
<v-card-title class="d-flex align-center" style="gap: 10px;"> <CardHeading icon="mdi-pulse" title="Queues + workers">
<v-icon icon="mdi-pulse" size="small" />
<span>Queues + workers</span>
<v-spacer /> <v-spacer />
<span class="text-caption fc-muted"> <span class="text-caption fc-muted">
updated {{ formatRelative(store.queues?.fetched_at) }} updated {{ formatRelative(store.queues?.fetched_at) }}
</span> </span>
</v-card-title> </CardHeading>
<v-card-text> <v-card-text>
<QueuesTable <QueuesTable
:queues="store.queues" :queues="store.queues"
@@ -21,14 +19,12 @@
<!-- Recent failures pane --> <!-- Recent failures pane -->
<v-card class="mb-4"> <v-card class="mb-4">
<v-card-title class="d-flex align-center" style="gap: 10px;"> <CardHeading icon="mdi-alert-circle-outline" title="Recent failures (last 24h)">
<v-icon icon="mdi-alert-circle-outline" size="small" />
<span>Recent failures (last 24h)</span>
<v-spacer /> <v-spacer />
<span class="text-caption fc-muted"> <span class="text-caption fc-muted">
{{ failureCount }} failures {{ failureCount }} failures
</span> </span>
</v-card-title> </CardHeading>
<v-card-text> <v-card-text>
<div v-if="errorTypes.length" class="mb-3 fc-pills"> <div v-if="errorTypes.length" class="mb-3 fc-pills">
<v-chip <v-chip
@@ -79,9 +75,7 @@
<!-- All activity pane --> <!-- All activity pane -->
<v-card> <v-card>
<v-card-title class="d-flex align-center" style="gap: 10px;"> <CardHeading icon="mdi-format-list-bulleted" title="All recent activity">
<v-icon icon="mdi-format-list-bulleted" size="small" />
<span>All recent activity</span>
<v-spacer /> <v-spacer />
<v-select <v-select
v-model="filterQueue" v-model="filterQueue"
@@ -102,7 +96,7 @@
<v-icon start size="small">mdi-refresh</v-icon> <v-icon start size="small">mdi-refresh</v-icon>
Refresh Refresh
</v-btn> </v-btn>
</v-card-title> </CardHeading>
<v-card-text> <v-card-text>
<v-table density="compact"> <v-table density="compact">
<thead> <thead>
@@ -157,6 +151,7 @@ import { useSystemActivityStore } from '../../stores/systemActivity.js'
import { formatRelative as fmtRelative } from '../../utils/date.js' import { formatRelative as fmtRelative } from '../../utils/date.js'
import ErrorDetailModal from '../common/ErrorDetailModal.vue' import ErrorDetailModal from '../common/ErrorDetailModal.vue'
import QueuesTable from './QueuesTable.vue' import QueuesTable from './QueuesTable.vue'
import CardHeading from '../common/CardHeading.vue'
// Click-to-open modal for full error text. Replaces the unusable // Click-to-open modal for full error text. Replaces the unusable
// :title="..." tooltip (operator-flagged 2026-05-26: SQLAlchemy // :title="..." tooltip (operator-flagged 2026-05-26: SQLAlchemy
@@ -305,5 +300,4 @@ function formatRelative(iso) {
cursor: pointer; cursor: pointer;
} }
.fc-err-link:hover { text-decoration: underline; } .fc-err-link:hover { text-decoration: underline; }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
</style> </style>
@@ -1,9 +1,6 @@
<template> <template>
<v-card class="fc-tag-maint"> <v-card class="fc-tag-maint">
<v-card-title class="d-flex align-center" style="gap: 10px;"> <CardHeading icon="mdi-tag-remove" title="Tag maintenance" />
<v-icon icon="mdi-tag-remove" size="small" />
<span>Tag maintenance</span>
</v-card-title>
<v-card-text> <v-card-text>
<p class="fc-muted text-body-2 mb-4"> <p class="fc-muted text-body-2 mb-4">
Remove tags with zero image associations and zero series-page Remove tags with zero image associations and zero series-page
@@ -31,11 +28,10 @@
Showing first 50 names. Showing first 50 names.
</span> </span>
</p> </p>
<div v-if="preview.sample_names?.length" class="fc-name-grid mb-3"> <SampleNameGrid
<span v-for="n in preview.sample_names" :key="n" class="fc-name"> v-if="preview.sample_names?.length"
{{ n }} :names="preview.sample_names" class="mb-3"
</span> />
</div>
<v-btn <v-btn
color="error" variant="flat" rounded="pill" color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-sweep" prepend-icon="mdi-delete-sweep"
@@ -76,11 +72,10 @@
{{ p }}: {{ n }}&nbsp;&nbsp; {{ p }}: {{ n }}&nbsp;&nbsp;
</span> </span>
</p> </p>
<div v-if="kindPreview.sample_names?.length" class="fc-name-grid mb-3"> <SampleNameGrid
<span v-for="n in kindPreview.sample_names" :key="n" class="fc-name"> v-if="kindPreview.sample_names?.length"
{{ n }} :names="kindPreview.sample_names" class="mb-3"
</span> />
</div>
<v-btn <v-btn
color="error" variant="flat" rounded="pill" color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-sweep" prepend-icon="mdi-delete-sweep"
@@ -122,11 +117,10 @@
across <strong>{{ resetPreview.applications }}</strong> image across <strong>{{ resetPreview.applications }}</strong> image
application(s). application(s).
</p> </p>
<div v-if="resetPreview.sample_names?.length" class="fc-name-grid mb-3"> <SampleNameGrid
<span v-for="n in resetPreview.sample_names" :key="n" class="fc-name"> v-if="resetPreview.sample_names?.length"
{{ n }} :names="resetPreview.sample_names" class="mb-3"
</span> />
</div>
<v-btn <v-btn
color="error" variant="flat" rounded="pill" color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-alert" prepend-icon="mdi-delete-alert"
@@ -170,14 +164,14 @@
<strong>{{ normPreview.collisions }}</strong> collision(s) merging <strong>{{ normPreview.collisions }}</strong> collision(s) merging
<strong>{{ normPreview.tags_to_merge }}</strong> tag(s) away. <strong>{{ normPreview.tags_to_merge }}</strong> tag(s) away.
</p> </p>
<div v-if="normPreview.sample?.length" class="fc-name-grid mb-3"> <SampleNameGrid v-if="normPreview.sample?.length" class="mb-3">
<span <span
v-for="s in normPreview.sample" :key="s.to + s.kind" v-for="s in normPreview.sample" :key="s.to + s.kind"
class="fc-name" class="fc-name"
> >
<template v-if="s.merge">{{ s.from.join(' + ') }} → </template>{{ s.to }} <template v-if="s.merge">{{ s.from.join(' + ') }} → </template>{{ s.to }}
</span> </span>
</div> </SampleNameGrid>
<v-btn <v-btn
color="error" variant="flat" rounded="pill" color="error" variant="flat" rounded="pill"
prepend-icon="mdi-format-letter-case" prepend-icon="mdi-format-letter-case"
@@ -198,6 +192,8 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useAdminStore } from '../../stores/admin.js' import { useAdminStore } from '../../stores/admin.js'
import SampleNameGrid from '../common/SampleNameGrid.vue'
import CardHeading from '../common/CardHeading.vue'
const store = useAdminStore() const store = useAdminStore()
const preview = ref(null) const preview = ref(null)
@@ -304,16 +300,4 @@ async function onNormCommit() {
<style scoped> <style scoped>
.fc-tag-maint { border-radius: 8px; } .fc-tag-maint { border-radius: 8px; }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-name-grid {
display: flex; flex-wrap: wrap; gap: 4px 8px;
max-height: 200px; overflow-y: auto;
padding: 8px; border-radius: 4px;
background: rgb(var(--v-theme-surface-light));
}
.fc-name {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: rgb(var(--v-theme-on-surface-variant));
}
</style> </style>
@@ -6,10 +6,10 @@
@update:model-value="$emit('update:modelValue', $event)" @update:model-value="$emit('update:modelValue', $event)"
> >
<v-card v-if="source"> <v-card v-if="source">
<v-card-title class="d-flex align-center"> <CardHeading
<v-icon class="mr-2">mdi-eye-outline</v-icon> icon="mdi-eye-outline"
Preview · {{ source.artist_name }} :title="`Preview · ${source.artist_name}`"
</v-card-title> />
<v-card-subtitle class="fc-preview__url">{{ source.url }}</v-card-subtitle> <v-card-subtitle class="fc-preview__url">{{ source.url }}</v-card-subtitle>
<v-card-text> <v-card-text>
@@ -70,6 +70,7 @@
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { useSourcesStore } from '../../stores/sources.js' import { useSourcesStore } from '../../stores/sources.js'
import { formatRelative } from '../../utils/date.js' import { formatRelative } from '../../utils/date.js'
import CardHeading from '../common/CardHeading.vue'
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
@@ -26,40 +26,33 @@
</v-tooltip> </v-tooltip>
</v-btn> </v-btn>
<v-menu location="bottom end"> <KebabMenu size="small" :min-width="260" label="More actions">
<template #activator="{ props: menuProps }"> <v-list-item
<v-btn icon size="small" variant="text" v-bind="menuProps" @click.stop> v-if="isPatreon && !running"
<v-icon>mdi-dots-vertical</v-icon> prepend-icon="mdi-backup-restore"
<v-tooltip activator="parent" location="top">More actions</v-tooltip> @click="emit('recover', source)"
</v-btn> >
</template> <v-list-item-title>Recover dropped near-duplicates</v-list-item-title>
<v-list density="compact" min-width="260"> <v-list-item-subtitle>
<v-list-item Re-fetch images previously dropped as near-dups and re-judge them
v-if="isPatreon && !running" under the current similarity threshold
prepend-icon="mdi-backup-restore" </v-list-item-subtitle>
@click="emit('recover', source)" </v-list-item>
> <v-divider v-if="isPatreon && !running" />
<v-list-item-title>Recover dropped near-duplicates</v-list-item-title> <v-list-item
<v-list-item-subtitle> base-color="error"
Re-fetch images previously dropped as near-dups and re-judge them prepend-icon="mdi-delete-outline"
under the current similarity threshold @click="emit('remove', source)"
</v-list-item-subtitle> >
</v-list-item> <v-list-item-title>Remove source</v-list-item-title>
<v-divider v-if="isPatreon && !running" /> </v-list-item>
<v-list-item </KebabMenu>
base-color="error"
prepend-icon="mdi-delete-outline"
@click="emit('remove', source)"
>
<v-list-item-title>Remove source</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import KebabMenu from '../common/KebabMenu.vue'
const props = defineProps({ const props = defineProps({
source: { type: Object, required: true }, source: { type: Object, required: true },
+21 -14
View File
@@ -2,14 +2,12 @@ import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router'
import SettingsView from './views/SettingsView.vue' import SettingsView from './views/SettingsView.vue'
import GalleryView from './views/GalleryView.vue' import GalleryView from './views/GalleryView.vue'
import ShowcaseView from './views/ShowcaseView.vue' import ShowcaseView from './views/ShowcaseView.vue'
import TagsView from './views/TagsView.vue' import BrowseView from './views/BrowseView.vue'
import ArtistView from './views/ArtistView.vue' import ArtistView from './views/ArtistView.vue'
import SeriesView from './views/SeriesView.vue' import SeriesView from './views/SeriesView.vue'
import SeriesManageView from './views/SeriesManageView.vue' import SeriesManageView from './views/SeriesManageView.vue'
import SeriesReaderView from './views/SeriesReaderView.vue' import SeriesReaderView from './views/SeriesReaderView.vue'
import SubscriptionsView from './views/SubscriptionsView.vue' import SubscriptionsView from './views/SubscriptionsView.vue'
import PostsView from './views/PostsView.vue'
import ArtistsView from './views/ArtistsView.vue'
// The application's front door. `/` redirects here. Changing the front door // The application's front door. `/` redirects here. Changing the front door
// is a one-line edit (e.g. '/gallery' or '/tags'). // is a one-line edit (e.g. '/gallery' or '/tags').
@@ -22,27 +20,36 @@ const routes = [
// FC-2: image backbone // FC-2: image backbone
{ path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase' } }, { path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase' } },
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery' } }, { path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery' } },
{ path: '/artists', name: 'artists', component: ArtistsView, meta: { title: 'Artists' } }, // Browse hub (operator-asked 2026-06-09): Posts / Artists / Tags as tabs —
{ path: '/tags', name: 'tags', component: TagsView, meta: { title: 'Tags' } }, // the three "browse the library by an axis" surfaces. One nav entry; the old
// standalone paths redirect into the matching tab (below).
{ path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse' } },
// Artist detail — no meta.title (reached by clicking an artist, not nav). // Artist detail — no meta.title (reached by clicking an artist, not nav).
{ path: '/artist/:slug', name: 'artist', component: ArtistView }, { path: '/artist/:slug', name: 'artist', component: ArtistView },
// Series browse — a nav entry (meta.title). Peer of Posts. // Series browse — a nav entry (meta.title).
{ path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series' } }, { path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series' } },
// Series management — no meta.title (reached from a series card/tag). // Series management — no meta.title (reached from a series card/tag).
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView }, { path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
// Series reader — immersive (no top nav, no meta.title). // Series reader — immersive (no top nav, no meta.title).
{ path: '/series/:tagId/read', name: 'series-read', component: SeriesReaderView, meta: { immersive: true } }, { path: '/series/:tagId/read', name: 'series-read', component: SeriesReaderView, meta: { immersive: true } },
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
// FC-3: subscription backbone // FC-3: subscription backbone — purely management (sources/downloads),
// /credentials and /downloads were folded into /subscriptions as subtabs // distinct from the Browse hub.
// 2026-05-27 (?tab=settings and ?tab=downloads). The hub view owns the
// whole download pipeline domain.
{ path: '/posts', name: 'posts', component: PostsView, meta: { title: 'Posts' } },
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions' } }, { path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions' } },
// Bookmark/back-button safety net for the routes that got folded in // Settings — config, pinned to the right of the nav (TopNav special-cases it).
// (no meta.title — stay out of TopNav). { path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
// The old standalone paths now redirect into the Browse hub, preserving any
// deep-link query (e.g. /posts?post_id=N → /browse?tab=posts&post_id=N). The
// route NAMES stay so existing { name: 'posts' | 'artists' | 'tags' } links
// and path pushes keep resolving.
{ path: '/posts', name: 'posts', redirect: (to) => ({ name: 'browse', query: { ...to.query, tab: 'posts' } }) },
{ path: '/artists', name: 'artists', redirect: (to) => ({ name: 'browse', query: { ...to.query, tab: 'artists' } }) },
{ path: '/tags', name: 'tags', redirect: (to) => ({ name: 'browse', query: { ...to.query, tab: 'tags' } }) },
// Bookmark/back-button safety net for the routes that got folded into
// /subscriptions (no meta.title — stay out of TopNav).
{ path: '/credentials', redirect: '/subscriptions?tab=settings' }, { path: '/credentials', redirect: '/subscriptions?tab=settings' },
{ path: '/downloads', redirect: '/subscriptions?tab=downloads' } { path: '/downloads', redirect: '/subscriptions?tab=downloads' }
] ]
+8
View File
@@ -31,3 +31,11 @@
border: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.25); border: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.25);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
} }
/* Canonical muted/secondary text token (DRY pattern sweep 2026-06-09).
Was redefined identically in 12 component <style scoped> blocks; now one
global utility. Muted text uses the explicit on-surface-variant (vellum)
token, NOT opacity — Vuetify's text-medium-emphasis is opacity-based and is
deliberately not used here. `.fc-muted` is a custom class Vuetify never
emits, so no specificity/reorder fight — no !important needed. */
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
+47
View File
@@ -0,0 +1,47 @@
<template>
<div class="fc-browse">
<v-container fluid class="pt-2 pb-0">
<v-tabs v-model="tab" density="compact">
<v-tab value="posts">Posts</v-tab>
<v-tab value="artists">Artists</v-tab>
<v-tab value="tags">Tags</v-tab>
</v-tabs>
</v-container>
<!-- Each tab's view is self-contained (its own container + state). Only the
active one is mounted; switching tabs swaps it. The Posts tab still
reads its deep-link (post_id/artist_id/platform) from route.query, so
/browse?tab=posts&post_id=N works exactly like the old /posts deep link.
(FC nav consolidation, operator-asked 2026-06-09: Posts/Artists/Tags are
the three "browse the library by an axis" surfaces.) -->
<component :is="activeComponent" :key="tab" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ArtistsView from './ArtistsView.vue'
import PostsView from './PostsView.vue'
import TagsView from './TagsView.vue'
const TABS = { posts: PostsView, artists: ArtistsView, tags: TagsView }
const route = useRoute()
const router = useRouter()
const tab = computed({
get() {
const t = route.query.tab
return typeof t === 'string' && t in TABS ? t : 'posts'
},
set(value) {
// Switching tabs starts a clean tab — a posts deep link (post_id, etc.)
// belongs only to the Posts tab and shouldn't bleed into Artists/Tags.
router.replace({ name: 'browse', query: { tab: value } })
},
})
const activeComponent = computed(() => TABS[tab.value])
</script>
-1
View File
@@ -29,5 +29,4 @@ import TagMaintenanceCard from '../components/settings/TagMaintenanceCard.vue'
<style scoped> <style scoped>
.fc-cleanup { max-width: 900px; } .fc-cleanup { max-width: 900px; }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
</style> </style>
+47 -31
View File
@@ -2,6 +2,12 @@
<v-container fluid class="pt-2 pb-10 fc-series"> <v-container fluid class="pt-2 pb-10 fc-series">
<div class="fc-series__head"> <div class="fc-series__head">
<span class="fc-series__name">{{ store.series?.name || 'Series' }}</span> <span class="fc-series__name">{{ store.series?.name || 'Series' }}</span>
<v-btn
v-if="store.series"
icon="mdi-pencil" size="x-small" variant="text"
:aria-label="`Rename ${store.series.name}`"
@click="renameOpen = true"
/>
<span class="fc-series__count"> <span class="fc-series__count">
{{ store.chapters.length }} part(s) · {{ store.pageCount }} page(s) {{ store.chapters.length }} part(s) · {{ store.pageCount }} page(s)
</span> </span>
@@ -67,37 +73,29 @@
@click="openPicker(ch.id)" @click="openPicker(ch.id)"
>Add pages</v-btn> >Add pages</v-btn>
<v-menu location="bottom end"> <KebabMenu size="small" label="Part actions">
<template #activator="{ props }"> <v-list-item
<v-btn prepend-icon="mdi-chevron-up" title="Move up"
v-bind="props" size="small" variant="text" :disabled="ci === 0"
icon="mdi-dots-vertical" title="Part actions" @click="store.moveChapter(ch.id, -1)"
/> />
</template> <v-list-item
<v-list density="compact"> prepend-icon="mdi-chevron-down" title="Move down"
<v-list-item :disabled="ci === store.chapters.length - 1"
prepend-icon="mdi-chevron-up" title="Move up" @click="store.moveChapter(ch.id, 1)"
:disabled="ci === 0" />
@click="store.moveChapter(ch.id, -1)" <v-list-item
/> prepend-icon="mdi-arrow-collapse-up" title="Merge into previous"
<v-list-item :disabled="ci === 0"
prepend-icon="mdi-chevron-down" title="Move down" @click="store.mergeChapter(ch.id, store.chapters[ci - 1].id)"
:disabled="ci === store.chapters.length - 1" />
@click="store.moveChapter(ch.id, 1)" <v-divider />
/> <v-list-item
<v-list-item prepend-icon="mdi-delete-outline" title="Delete part"
prepend-icon="mdi-arrow-collapse-up" title="Merge into previous" base-color="error"
:disabled="ci === 0" @click="confirmDelete(ch)"
@click="store.mergeChapter(ch.id, store.chapters[ci - 1].id)" />
/> </KebabMenu>
<v-divider />
<v-list-item
prepend-icon="mdi-delete-outline" title="Delete part"
base-color="error"
@click="confirmDelete(ch)"
/>
</v-list>
</v-menu>
</header> </header>
<div class="fc-part__statedrow"> <div class="fc-part__statedrow">
@@ -222,6 +220,14 @@
<div ref="sentinel" class="fc-picker__sentinel" /> <div ref="sentinel" class="fc-picker__sentinel" />
</div> </div>
</v-navigation-drawer> </v-navigation-drawer>
<v-dialog v-model="renameOpen" max-width="420">
<TagRenameDialog
v-if="store.series"
:tag="{ id: store.tagId, name: store.series.name, kind: 'series' }"
@renamed="onRenamed" @cancel="renameOpen = false"
/>
</v-dialog>
</v-container> </v-container>
</template> </template>
@@ -230,6 +236,8 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js' import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js' import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
import KebabMenu from '../components/common/KebabMenu.vue'
import TagRenameDialog from '../components/modal/TagRenameDialog.vue'
const route = useRoute() const route = useRoute()
const store = useSeriesManageStore() const store = useSeriesManageStore()
@@ -238,6 +246,14 @@ const drag = ref(null) // { chapterId, idx }
const titleDraft = reactive({}) // chapterId -> draft title const titleDraft = reactive({}) // chapterId -> draft title
const partDraft = reactive({}) // chapterId -> draft stated_part (string) const partDraft = reactive({}) // chapterId -> draft stated_part (string)
const pickerOpen = ref(false) const pickerOpen = ref(false)
const renameOpen = ref(false)
// A series IS a Tag(kind=series); TagRenameDialog PATCHes /api/tags/<id> and
// handles the same-name collision→merge flow. Reflect the new name in place.
function onRenamed(updated) {
renameOpen.value = false
if (store.series && updated?.name) store.series.name = updated.name
}
// Keep local drafts in sync with loaded chapters. Edits commit on blur/Enter, // Keep local drafts in sync with loaded chapters. Edits commit on blur/Enter,
// which refreshes and resets the draft to the saved value. // which refreshes and resets the draft to the saved value.
+15 -21
View File
@@ -60,27 +60,20 @@
<span v-if="s.has_gap" class="fc-sbcard__gap" title="Has a missing-page gap"> <span v-if="s.has_gap" class="fc-sbcard__gap" title="Has a missing-page gap">
<v-icon size="x-small">mdi-alert-outline</v-icon> gap <v-icon size="x-small">mdi-alert-outline</v-icon> gap
</span> </span>
<v-menu> <KebabMenu
<template #activator="{ props: menuProps }"> class="fc-sbcard__kebab" variant="flat"
<v-btn :label="`Actions for ${s.name}`"
v-bind="menuProps" >
icon="mdi-dots-vertical" size="x-small" variant="flat" <v-list-item prepend-icon="mdi-pencil" @click.stop="openRename(s)">
class="fc-sbcard__kebab" :aria-label="`Actions for ${s.name}`" <v-list-item-title>Rename</v-list-item-title>
@click.stop </v-list-item>
/> <v-list-item
</template> prepend-icon="mdi-delete" base-color="error"
<v-list density="compact"> @click.stop="openDelete(s)"
<v-list-item prepend-icon="mdi-pencil" @click.stop="openRename(s)"> >
<v-list-item-title>Rename</v-list-item-title> <v-list-item-title>Delete</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item </KebabMenu>
prepend-icon="mdi-delete" base-color="error"
@click.stop="openDelete(s)"
>
<v-list-item-title>Delete</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div> </div>
<div class="fc-sbcard__body"> <div class="fc-sbcard__body">
<h3 class="fc-sbcard__name">{{ s.name }}</h3> <h3 class="fc-sbcard__name">{{ s.name }}</h3>
@@ -210,6 +203,7 @@ import { useRouter } from 'vue-router'
import { useSeriesBrowseStore } from '../stores/seriesBrowse.js' import { useSeriesBrowseStore } from '../stores/seriesBrowse.js'
import { useSeriesSuggestionsStore } from '../stores/seriesSuggestions.js' import { useSeriesSuggestionsStore } from '../stores/seriesSuggestions.js'
import TagRenameDialog from '../components/modal/TagRenameDialog.vue' import TagRenameDialog from '../components/modal/TagRenameDialog.vue'
import KebabMenu from '../components/common/KebabMenu.vue'
const router = useRouter() const router = useRouter()
const store = useSeriesBrowseStore() const store = useSeriesBrowseStore()
@@ -0,0 +1,38 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import KebabMenu from '../../src/components/common/KebabMenu.vue'
// Canonical kebab primitive (DRY pattern-consistency sweep). Vuetify components
// are left unresolved by the test harness, so we assert the wiring the consumers
// rely on: the trigger's aria-label, the default kebab glyph, and that the
// consumer's menu items (default slot) are rendered inside the menu.
function mountKebab (props = {}) {
return mount(KebabMenu, {
props,
slots: {
default: '<v-list-item>Rename</v-list-item><v-list-item>Delete</v-list-item>',
},
})
}
describe('KebabMenu', () => {
it('renders the trigger with the provided aria-label and the slotted items', () => {
const w = mountKebab({ label: 'Actions for Foo' })
expect(w.text()).toContain('Rename')
expect(w.text()).toContain('Delete')
expect(w.html()).toContain('Actions for Foo')
})
it('defaults the trigger glyph to the kebab icon', () => {
expect(mountKebab().html()).toContain('mdi-dots-vertical')
})
it('passes presentational props through to the trigger/menu', () => {
const w = mountKebab({ size: 'small', variant: 'flat', location: 'bottom end' })
const html = w.html()
expect(html).toContain('small')
expect(html).toContain('flat')
})
})
+18 -2
View File
@@ -17,13 +17,29 @@ describe('router', () => {
expect(router.resolve('/gallery').name).toBe('gallery') expect(router.resolve('/gallery').name).toBe('gallery')
}) })
it('showcase, tags, artist resolve to named routes', () => { it('showcase, artist resolve to named routes', () => {
expect(router.resolve('/showcase').name).toBe('showcase') expect(router.resolve('/showcase').name).toBe('showcase')
expect(router.resolve('/tags').name).toBe('tags')
expect(router.resolve('/artist/some-slug').name).toBe('artist') expect(router.resolve('/artist/some-slug').name).toBe('artist')
expect(router.resolve('/artist/some-slug').params.slug).toBe('some-slug') expect(router.resolve('/artist/some-slug').params.slug).toBe('some-slug')
}) })
it('browse hub is reachable and old axis paths redirect into its tabs', async () => {
expect(router.resolve('/browse').name).toBe('browse')
await router.push('/tags')
expect(router.currentRoute.value.name).toBe('browse')
expect(router.currentRoute.value.query.tab).toBe('tags')
await router.push('/artists')
expect(router.currentRoute.value.query.tab).toBe('artists')
// A posts deep link survives the redirect into the hub tab.
await router.push({ path: '/posts', query: { post_id: '7' } })
expect(router.currentRoute.value.name).toBe('browse')
expect(router.currentRoute.value.query.tab).toBe('posts')
expect(router.currentRoute.value.query.post_id).toBe('7')
})
it('series-read is an immersive route', () => { it('series-read is an immersive route', () => {
const r = router.resolve('/series/5/read') const r = router.resolve('/series/5/read')
expect(r.name).toBe('series-read') expect(r.name).toBe('series-read')
+34
View File
@@ -0,0 +1,34 @@
"""Race-safe get_or_create helper (shared by the async services)."""
import pytest
from sqlalchemy import select
from backend.app.models import Artist
from backend.app.services.db_helpers import get_or_create
pytestmark = pytest.mark.integration
@pytest.mark.asyncio
async def test_get_or_create_creates_then_returns_existing(db):
stmt = select(Artist).where(Artist.slug == "goc-test")
factory_calls = {"n": 0}
async def _make():
factory_calls["n"] += 1
a = Artist(name="GOC Test", slug="goc-test")
db.add(a)
await db.flush()
return a
row1, created1 = await get_or_create(db, stmt, _make)
assert created1 is True
assert row1.slug == "goc-test"
assert factory_calls["n"] == 1
# Second call finds the existing row and does NOT invoke the factory.
row2, created2 = await get_or_create(db, stmt, _make)
assert created2 is False
assert row2.id == row1.id
assert factory_calls["n"] == 1 # factory not called when the row exists
+29
View File
@@ -0,0 +1,29 @@
"""serialize_tag — the shared canonical tag-dict serializer."""
from types import SimpleNamespace
from backend.app.models import TagKind
from backend.app.services.tag_query import serialize_tag
def test_serialize_tag_with_enum_kind():
# ORM/column rows carry kind as a TagKind enum.
row = SimpleNamespace(
id=1, name="Sasuke Uchiha", kind=TagKind.character,
fandom_id=5, fandom_name="Naruto",
)
assert serialize_tag(row) == {
"id": 1, "name": "Sasuke Uchiha", "kind": "character",
"fandom_id": 5, "fandom_name": "Naruto",
}
def test_serialize_tag_with_string_kind_and_no_fandom():
# autocomplete hits already store kind as a plain string.
row = SimpleNamespace(
id=2, name="solo", kind="general", fandom_id=None, fandom_name=None,
)
assert serialize_tag(row) == {
"id": 2, "name": "solo", "kind": "general",
"fandom_id": None, "fandom_name": None,
}