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
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:
+3
-23
@@ -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"])
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"> {{ fandomLabel }}</template></span>
|
>→<template v-if="fandomLabel"> {{ 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 }}
|
{{ p }}: {{ n }}
|
||||||
</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
@@ -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' }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)); }
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user