Merge pull request 'Browse search + series numbering rework + kebab fix' (#98) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 2m50s
CI / integration (push) Successful in 3m16s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 2m50s
CI / integration (push) Successful in 3m16s
This commit was merged in pull request #98.
This commit is contained in:
@@ -17,6 +17,7 @@ async def list_posts():
|
||||
cursor = args.get("cursor") or None
|
||||
artist_id_raw = args.get("artist_id")
|
||||
platform = args.get("platform") or None
|
||||
q = (args.get("q") or "").strip() or None
|
||||
limit_raw = args.get("limit", "24")
|
||||
direction = args.get("direction", "older")
|
||||
around_raw = args.get("around")
|
||||
@@ -56,7 +57,7 @@ async def list_posts():
|
||||
if around_id is not None:
|
||||
result = await svc.around(
|
||||
post_id=around_id, artist_id=artist_id,
|
||||
platform=platform, limit=limit,
|
||||
platform=platform, q=q, limit=limit,
|
||||
)
|
||||
if result is None:
|
||||
return _bad("not_found", status=404, detail=f"post id={around_id}")
|
||||
@@ -64,7 +65,7 @@ async def list_posts():
|
||||
try:
|
||||
page = await svc.scroll(
|
||||
cursor=cursor, artist_id=artist_id,
|
||||
platform=platform, limit=limit, direction=direction,
|
||||
platform=platform, q=q, limit=limit, direction=direction,
|
||||
)
|
||||
except ValueError as exc:
|
||||
# Service raises ValueError for malformed cursors only;
|
||||
|
||||
+27
-15
@@ -415,15 +415,26 @@ async def series_remove(tag_id: int):
|
||||
return jsonify({"removed_count": n})
|
||||
|
||||
|
||||
@tags_bp.route("/series/<int:tag_id>/reorder", methods=["POST"])
|
||||
async def series_reorder(tag_id: int):
|
||||
body = await request.get_json()
|
||||
ids, err = _parse_bulk_ids(body, max_ids=500)
|
||||
if err:
|
||||
return err
|
||||
@tags_bp.route("/series/<int:tag_id>/pages/number", methods=["POST"])
|
||||
async def series_set_page_number(tag_id: int):
|
||||
"""Set one placed page's number — the operator's value (sparse, gaps
|
||||
allowed); pass page_number: null to leave it unnumbered."""
|
||||
body = await request.get_json() or {}
|
||||
image_id, ierr = _opt_int(body, "image_id")
|
||||
if ierr:
|
||||
return ierr
|
||||
if image_id is None:
|
||||
return jsonify({"error": "image_id required"}), 400
|
||||
if "page_number" not in body:
|
||||
return jsonify({"error": "page_number required (may be null)"}), 400
|
||||
page_number, perr = _opt_int(body, "page_number")
|
||||
if perr:
|
||||
return perr
|
||||
async with get_session() as session:
|
||||
try:
|
||||
await SeriesService(session).reorder(tag_id, ids)
|
||||
await SeriesService(session).set_page_number(
|
||||
tag_id, image_id, page_number
|
||||
)
|
||||
except SeriesError as exc:
|
||||
return _series_err(exc)
|
||||
await session.commit()
|
||||
@@ -450,8 +461,9 @@ async def series_cover(tag_id: int):
|
||||
|
||||
# ---- chapter dividers (FC-6.x) -------------------------------------------
|
||||
# A chapter is a cosmetic divider anchored to the page that begins it; it owns
|
||||
# no pages. Page ordering is series-wide (the /reorder endpoint above), so there
|
||||
# is no per-chapter reorder/merge/chapter-reorder — those are gone.
|
||||
# no pages. Page ordering follows each page's operator-set number (the
|
||||
# /pages/number endpoint), so there is no per-chapter reorder/merge — those are
|
||||
# gone.
|
||||
|
||||
|
||||
@tags_bp.route("/series/<int:tag_id>/chapters", methods=["POST"])
|
||||
@@ -585,19 +597,19 @@ async def series_add_post(tag_id: int):
|
||||
|
||||
@tags_bp.route("/series/<int:tag_id>/pending/place", methods=["POST"])
|
||||
async def series_place_pending(tag_id: int):
|
||||
"""Move staged (pending) pages into the placed run — before `before_image_id`
|
||||
(a placed page) or appended when omitted (#789 P2)."""
|
||||
"""Place staged (pending) pages into the run, numbered sequentially from
|
||||
`start_page` in the given order (#789). start_page null → unnumbered."""
|
||||
body = await request.get_json()
|
||||
ids, err = _parse_bulk_ids(body, max_ids=500)
|
||||
if err:
|
||||
return err
|
||||
before, berr = _opt_int(body, "before_image_id")
|
||||
if berr:
|
||||
return berr
|
||||
start, serr = _opt_int(body, "start_page")
|
||||
if serr:
|
||||
return serr
|
||||
async with get_session() as session:
|
||||
try:
|
||||
n = await SeriesService(session).place_pending(
|
||||
tag_id, ids, before_image_id=before
|
||||
tag_id, ids, start_page=start
|
||||
)
|
||||
except SeriesError as exc:
|
||||
return _series_err(exc)
|
||||
|
||||
@@ -45,6 +45,7 @@ class PostFeedService:
|
||||
cursor: str | None = None,
|
||||
artist_id: int | None = None,
|
||||
platform: str | None = None,
|
||||
q: str | None = None,
|
||||
limit: int = 24,
|
||||
direction: str = "older",
|
||||
) -> dict:
|
||||
@@ -52,7 +53,12 @@ class PostFeedService:
|
||||
time (default, infinite-scroll down); direction='newer' walks forward
|
||||
(scroll up in an anchored view). Items are always returned in feed
|
||||
(descending) order; `next_cursor` points to the far edge in the
|
||||
requested direction (null when exhausted)."""
|
||||
requested direction (null when exhausted).
|
||||
|
||||
`q` is a free-text filter (ILIKE substring over post_title OR
|
||||
description) applied INSIDE the artist/platform scope, so a search
|
||||
from the Browse bar stays within whatever artist is filtered in
|
||||
view (operator-asked 2026-06-11)."""
|
||||
if limit < 1 or limit > 100:
|
||||
raise ValueError("limit must be between 1 and 100")
|
||||
if direction not in ("older", "newer"):
|
||||
@@ -74,6 +80,12 @@ class PostFeedService:
|
||||
stmt = stmt.where(Post.artist_id == artist_id)
|
||||
if platform is not None:
|
||||
stmt = stmt.where(Source.platform == platform)
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
stmt = stmt.where(or_(
|
||||
Post.post_title.ilike(like),
|
||||
Post.description.ilike(like),
|
||||
))
|
||||
if cursor:
|
||||
cur_ts, cur_id = decode_cursor(cursor)
|
||||
if direction == "older":
|
||||
@@ -124,6 +136,7 @@ class PostFeedService:
|
||||
post_id: int,
|
||||
artist_id: int | None = None,
|
||||
platform: str | None = None,
|
||||
q: str | None = None,
|
||||
limit: int = 12,
|
||||
) -> dict | None:
|
||||
"""A window centered on `post_id`: up to `limit` newer posts + the
|
||||
@@ -143,11 +156,11 @@ class PostFeedService:
|
||||
|
||||
older = await self.scroll(
|
||||
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
|
||||
limit=limit, direction="older",
|
||||
q=q, limit=limit, direction="older",
|
||||
)
|
||||
newer = await self.scroll(
|
||||
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
|
||||
limit=limit, direction="newer",
|
||||
q=q, limit=limit, direction="newer",
|
||||
)
|
||||
thumbs_map = await self._thumbnails_for([anchor_post.id])
|
||||
atts_map = await self._attachments_for([anchor_post.id])
|
||||
|
||||
@@ -11,7 +11,7 @@ nearest preceding divider. An image lives in at most one series
|
||||
All mutations are Core/set-based and run in the request transaction.
|
||||
"""
|
||||
|
||||
from sqlalchemy import and_, func, select, text, update
|
||||
from sqlalchemy import and_, func, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -69,7 +69,10 @@ class SeriesService:
|
||||
SeriesPage.status == "placed",
|
||||
)
|
||||
)
|
||||
.order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc())
|
||||
.order_by(
|
||||
SeriesPage.page_number.asc().nulls_last(),
|
||||
SeriesPage.id.asc(),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
return list(rows)
|
||||
@@ -88,25 +91,6 @@ class SeriesService:
|
||||
raise SeriesError(f"image {image_id} is not in this series")
|
||||
return pid
|
||||
|
||||
async def _renumber(self, series_tag_id: int) -> None:
|
||||
"""Compact page_number to 1..N preserving current order (set-based)."""
|
||||
await self.session.execute(
|
||||
text(
|
||||
"""
|
||||
WITH ordered AS (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
ORDER BY page_number, id
|
||||
) AS rn
|
||||
FROM series_page WHERE series_tag_id = :sid
|
||||
)
|
||||
UPDATE series_page sp SET page_number = ordered.rn
|
||||
FROM ordered
|
||||
WHERE sp.id = ordered.id AND sp.page_number <> ordered.rn
|
||||
"""
|
||||
),
|
||||
{"sid": series_tag_id},
|
||||
)
|
||||
|
||||
async def _require_divider(self, series_tag_id: int, divider_id: int):
|
||||
row = (
|
||||
await self.session.execute(
|
||||
@@ -148,6 +132,30 @@ class SeriesService:
|
||||
prev = d
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _page_gaps(pages: list[dict]) -> list[dict]:
|
||||
"""Missing-number gaps between consecutive NUMBERED placed pages — one
|
||||
entry per gap (e.g. pages 2 then 5 → one gap 3–4). The UI renders each as
|
||||
a single block whose edges accept a dropped page. Unnumbered pages (at
|
||||
the tail) don't participate."""
|
||||
out: list[dict] = []
|
||||
prev = None
|
||||
for p in pages:
|
||||
n = p["page_number"]
|
||||
if n is None:
|
||||
continue
|
||||
if prev is not None and n > prev["page_number"] + 1:
|
||||
out.append(
|
||||
{
|
||||
"after_image_id": prev["image_id"],
|
||||
"before_image_id": p["image_id"],
|
||||
"from": prev["page_number"] + 1,
|
||||
"to": n - 1,
|
||||
}
|
||||
)
|
||||
prev = p
|
||||
return out
|
||||
|
||||
async def list_pages(self, series_tag_id: int) -> dict:
|
||||
tag = await self._require_series(series_tag_id)
|
||||
rows = (
|
||||
@@ -172,7 +180,10 @@ class SeriesService:
|
||||
SeriesPage.status == "placed",
|
||||
)
|
||||
)
|
||||
.order_by(SeriesPage.page_number.asc(), SeriesPage.id.asc())
|
||||
.order_by(
|
||||
SeriesPage.page_number.asc().nulls_last(),
|
||||
SeriesPage.id.asc(),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
pages = [
|
||||
@@ -259,6 +270,7 @@ class SeriesService:
|
||||
if r.primary_post_id is not None
|
||||
else None
|
||||
),
|
||||
"start_page": None,
|
||||
"pages": [],
|
||||
}
|
||||
by_post[r.primary_post_id] = grp
|
||||
@@ -273,10 +285,18 @@ class SeriesService:
|
||||
"image_url": f"/images/{r.path.split('/images/', 1)[-1]}",
|
||||
}
|
||||
)
|
||||
# Group start page = the post's parsed starting page (stored on the
|
||||
# staged pages); surfaced as the default for "Place from page N".
|
||||
if r.stated_page is not None:
|
||||
cur = grp["start_page"]
|
||||
grp["start_page"] = (
|
||||
r.stated_page if cur is None else min(cur, r.stated_page)
|
||||
)
|
||||
|
||||
return {
|
||||
"series": {"id": tag.id, "name": tag.name},
|
||||
"pages": pages,
|
||||
"gaps": self._page_gaps(pages),
|
||||
"dividers": dividers,
|
||||
"pending": pending_groups,
|
||||
"part_gaps": self._part_gaps(dividers),
|
||||
@@ -357,45 +377,58 @@ class SeriesService:
|
||||
)
|
||||
)
|
||||
# Dividers anchored to a removed page cascade away (chapter merges into
|
||||
# the preceding run); compact the remaining pages back to 1..N.
|
||||
await self._renumber(series_tag_id)
|
||||
# the preceding run). Page numbers are operator-set and sparse, so we do
|
||||
# NOT renumber — removing a page just leaves a gap.
|
||||
return res.rowcount or 0
|
||||
|
||||
async def reorder(
|
||||
self, series_tag_id: int, ordered_image_ids: list[int]
|
||||
async def set_page_number(
|
||||
self, series_tag_id: int, image_id: int, page_number: int | None
|
||||
) -> None:
|
||||
"""Series-wide reorder. ordered_image_ids must be exactly the series'
|
||||
pages; page_number is rewritten 1..N in that order."""
|
||||
"""Set one placed page's number — the operator's value: sparse, gaps
|
||||
allowed, or None to leave it unnumbered (sorts to the end). No other page
|
||||
is touched; reading order follows the numbers."""
|
||||
await self._require_series(series_tag_id)
|
||||
ordered = self._clean_ids(ordered_image_ids)
|
||||
current = set(await self._page_order(series_tag_id))
|
||||
if set(ordered) != current or len(ordered) != len(current):
|
||||
raise SeriesError(
|
||||
"ordered image_ids must exactly match the series' pages"
|
||||
)
|
||||
for idx, iid in enumerate(ordered, start=1):
|
||||
await self.session.execute(
|
||||
update(SeriesPage)
|
||||
.where(
|
||||
and_(
|
||||
SeriesPage.series_tag_id == series_tag_id,
|
||||
SeriesPage.image_id == iid,
|
||||
)
|
||||
res = await self.session.execute(
|
||||
update(SeriesPage)
|
||||
.where(
|
||||
and_(
|
||||
SeriesPage.series_tag_id == series_tag_id,
|
||||
SeriesPage.image_id == image_id,
|
||||
SeriesPage.status == "placed",
|
||||
)
|
||||
.values(page_number=idx)
|
||||
)
|
||||
.values(page_number=page_number)
|
||||
)
|
||||
if (res.rowcount or 0) == 0:
|
||||
raise SeriesError(
|
||||
f"image {image_id} is not a placed page of this series"
|
||||
)
|
||||
|
||||
async def set_cover(self, series_tag_id: int, image_id: int) -> None:
|
||||
"""Cover = the first page. Move the image to the front of the series."""
|
||||
"""Cover = the lowest-numbered page. Renumber the chosen image to sit
|
||||
just before the current minimum so it sorts first."""
|
||||
await self._require_series(series_tag_id)
|
||||
pages = await self._page_order(series_tag_id)
|
||||
if image_id not in pages:
|
||||
raise SeriesError(f"image {image_id} is not in this series")
|
||||
if pages and pages[0] != image_id:
|
||||
await self.reorder(
|
||||
series_tag_id,
|
||||
[image_id] + [p for p in pages if p != image_id],
|
||||
await self._page_id_for(series_tag_id, image_id) # membership guard
|
||||
min_other = await self.session.scalar(
|
||||
select(func.min(SeriesPage.page_number)).where(
|
||||
and_(
|
||||
SeriesPage.series_tag_id == series_tag_id,
|
||||
SeriesPage.status == "placed",
|
||||
SeriesPage.image_id != image_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
new_pn = (min_other - 1) if min_other is not None else 1
|
||||
await self.session.execute(
|
||||
update(SeriesPage)
|
||||
.where(
|
||||
and_(
|
||||
SeriesPage.series_tag_id == series_tag_id,
|
||||
SeriesPage.image_id == image_id,
|
||||
)
|
||||
)
|
||||
.values(page_number=new_pn)
|
||||
)
|
||||
|
||||
# ---- chapter dividers -------------------------------------------------
|
||||
|
||||
@@ -561,8 +594,10 @@ class SeriesService:
|
||||
SeriesPage.image_id.in_(to_stage)
|
||||
)
|
||||
)
|
||||
# Store the post's parsed START page on every staged page (constant), so
|
||||
# the group's start survives junk removal and seeds "Place from page N".
|
||||
rng = parse_page_range(f"{post.post_title or ''} {post.description or ''}")
|
||||
sp = self._stated_map(image_ids, rng[0] if rng else None) or {}
|
||||
start = rng[0] if rng else None
|
||||
await self.session.execute(
|
||||
pg_insert(SeriesPage).values(
|
||||
[
|
||||
@@ -571,7 +606,7 @@ class SeriesService:
|
||||
"image_id": iid,
|
||||
"status": "pending",
|
||||
"page_number": None,
|
||||
"stated_page": sp.get(iid),
|
||||
"stated_page": start,
|
||||
}
|
||||
for iid in to_stage
|
||||
]
|
||||
@@ -583,11 +618,13 @@ class SeriesService:
|
||||
self,
|
||||
series_tag_id: int,
|
||||
image_ids: list[int],
|
||||
before_image_id: int | None = None,
|
||||
start_page: int | None = None,
|
||||
) -> int:
|
||||
"""Move staged (pending) pages into the placed run — spliced in just
|
||||
before `before_image_id` (a placed page), or appended at the end when
|
||||
it's None. Page numbers are then rewritten 1..N across the run."""
|
||||
"""Place staged (pending) pages into the run, numbering them sequentially
|
||||
from `start_page` in the given order (start_page, start_page+1, …). With
|
||||
start_page None they're placed unnumbered (sorting to the tail). Drop the
|
||||
junk BEFORE placing so the numbers line up; only pending pages are
|
||||
touched, and other pages keep their numbers."""
|
||||
await self._require_series(series_tag_id)
|
||||
ids = self._clean_ids(image_ids)
|
||||
rows = dict(
|
||||
@@ -602,32 +639,11 @@ class SeriesService:
|
||||
)
|
||||
).all()
|
||||
)
|
||||
# Preserve the caller's order, restricted to actually-pending pages.
|
||||
staged = [i for i in ids if rows.get(i) == "pending"]
|
||||
if not staged:
|
||||
return 0
|
||||
placed = await self._page_order(series_tag_id)
|
||||
if before_image_id is not None and before_image_id not in placed:
|
||||
raise SeriesError(
|
||||
"before_image_id is not a placed page of this series"
|
||||
)
|
||||
new_order: list[int] = []
|
||||
for iid in placed:
|
||||
if iid == before_image_id:
|
||||
new_order.extend(staged)
|
||||
new_order.append(iid)
|
||||
if before_image_id is None:
|
||||
new_order.extend(staged)
|
||||
await self.session.execute(
|
||||
update(SeriesPage)
|
||||
.where(
|
||||
and_(
|
||||
SeriesPage.series_tag_id == series_tag_id,
|
||||
SeriesPage.image_id.in_(staged),
|
||||
)
|
||||
)
|
||||
.values(status="placed")
|
||||
)
|
||||
for idx, iid in enumerate(new_order, start=1):
|
||||
for offset, iid in enumerate(staged):
|
||||
await self.session.execute(
|
||||
update(SeriesPage)
|
||||
.where(
|
||||
@@ -636,7 +652,12 @@ class SeriesService:
|
||||
SeriesPage.image_id == iid,
|
||||
)
|
||||
)
|
||||
.values(page_number=idx)
|
||||
.values(
|
||||
status="placed",
|
||||
page_number=(
|
||||
start_page + offset if start_page is not None else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return len(staged)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
const cursor = ref(null)
|
||||
const { loading, error, run } = useAsyncAction()
|
||||
const done = ref(false)
|
||||
const filters = ref({ artist_id: null, platform: null })
|
||||
const filters = ref({ artist_id: null, platform: null, q: null })
|
||||
|
||||
// In-context (anchored) view: bidirectional cursors so the feed can load
|
||||
// newer posts on scroll-up and older posts on scroll-down around a post.
|
||||
@@ -32,6 +32,7 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
if (cursor.value) q.cursor = cursor.value
|
||||
if (filters.value.artist_id != null) q.artist_id = filters.value.artist_id
|
||||
if (filters.value.platform) q.platform = filters.value.platform
|
||||
if (filters.value.q) q.q = filters.value.q
|
||||
return q
|
||||
}
|
||||
|
||||
@@ -47,6 +48,7 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
filters.value = {
|
||||
artist_id: newFilters?.artist_id ?? null,
|
||||
platform: newFilters?.platform ?? null,
|
||||
q: newFilters?.q ?? null,
|
||||
}
|
||||
_reset()
|
||||
await loadMore()
|
||||
@@ -78,6 +80,7 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
const p = { ...extra }
|
||||
if (filters.value.artist_id != null) p.artist_id = filters.value.artist_id
|
||||
if (filters.value.platform) p.platform = filters.value.platform
|
||||
if (filters.value.q) p.q = filters.value.q
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -93,6 +96,7 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
filters.value = {
|
||||
artist_id: newFilters?.artist_id ?? null,
|
||||
platform: newFilters?.platform ?? null,
|
||||
q: newFilters?.q ?? null,
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
@@ -20,7 +20,8 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
const series = ref(null)
|
||||
const pages = ref([]) // flat, ordered: [{image_id, page_number, stated_page, thumbnail_url, image_url, source_post}]
|
||||
const dividers = ref([]) // [{id, anchor_image_id, page_number, title, stated_part}]
|
||||
const pending = ref([]) // staged-from-post: [{post:{id,title}|null, pages:[{image_id, stated_page, thumbnail_url, image_url}]}]
|
||||
const gaps = ref([]) // missing-number gaps: [{after_image_id, before_image_id, from, to}]
|
||||
const pending = ref([]) // staged-from-post: [{post:{id,title}|null, start_page, pages:[{image_id, stated_page, thumbnail_url, image_url}]}]
|
||||
const partGaps = ref([]) // [{after_divider_id, start, end}]
|
||||
const pageCount = ref(0)
|
||||
const picker = ref([]) // gallery scroll results
|
||||
@@ -36,6 +37,7 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
series.value = body.series
|
||||
pages.value = body.pages || []
|
||||
dividers.value = body.dividers || []
|
||||
gaps.value = body.gaps || []
|
||||
pending.value = body.pending || []
|
||||
partGaps.value = body.part_gaps || []
|
||||
pageCount.value = pages.value.length
|
||||
@@ -57,14 +59,19 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
return partGaps.value.find(g => g.after_divider_id === dividerId) || null
|
||||
}
|
||||
|
||||
// ---- pages (series-wide) ----
|
||||
async function reorder(orderedImageIds) {
|
||||
await api.post(`/api/series/${tagId.value}/reorder`, {
|
||||
body: { image_ids: orderedImageIds }
|
||||
// ---- pages (number-driven) ----
|
||||
// Set one page's number (sparse, gaps allowed; pass null to unnumber).
|
||||
async function setPageNumber(imageId, pageNumber) {
|
||||
await api.post(`/api/series/${tagId.value}/pages/number`, {
|
||||
body: { image_id: imageId, page_number: pageNumber }
|
||||
})
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function gapAfter(imageId) {
|
||||
return gaps.value.find(g => g.after_image_id === imageId) || null
|
||||
}
|
||||
|
||||
async function remove(imageId) {
|
||||
await api.post(`/api/series/${tagId.value}/pages/remove`, {
|
||||
body: { image_ids: [imageId] }
|
||||
@@ -108,9 +115,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
}
|
||||
|
||||
// ---- pending staging (add-from-post) ----
|
||||
async function placePending(imageIds, beforeImageId = null) {
|
||||
// Place pending pages, numbered sequentially from startPage in the given order.
|
||||
async function placePending(imageIds, startPage = null) {
|
||||
await api.post(`/api/series/${tagId.value}/pending/place`, {
|
||||
body: { image_ids: imageIds, before_image_id: beforeImageId }
|
||||
body: { image_ids: imageIds, start_page: startPage }
|
||||
})
|
||||
await refresh()
|
||||
toast({ text: 'Pages placed into the series', type: 'success' })
|
||||
@@ -143,10 +151,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
tagId, series, pages, dividers, pending, partGaps, pageCount,
|
||||
tagId, series, pages, dividers, gaps, pending, partGaps, pageCount,
|
||||
picker, pickerCursor, pickerSelection, loading,
|
||||
load, refresh, dividerAt, partGapAfter,
|
||||
reorder, remove, setCover, placePending,
|
||||
load, refresh, dividerAt, partGapAfter, gapAfter,
|
||||
setPageNumber, remove, setCover, placePending,
|
||||
createDivider, renameDivider, setDividerPart, deleteDivider,
|
||||
loadPicker, togglePick, addSelected
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
<template>
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
|
||||
<!-- Search lives in BrowseView's sticky bar (shared across tabs); this view
|
||||
reacts to route.query.q. The platform filter stays here. -->
|
||||
<div class="fc-artists__controls">
|
||||
<v-text-field
|
||||
v-model="search" density="compact" variant="outlined"
|
||||
prepend-inner-icon="mdi-magnify" hide-details
|
||||
placeholder="Search artists" clearable style="max-width: 320px;"
|
||||
/>
|
||||
<v-select
|
||||
v-model="platformModel"
|
||||
:items="platformItems"
|
||||
@@ -34,26 +31,25 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useArtistDirectoryStore } from '../stores/artistDirectory.js'
|
||||
import { usePlatformsStore } from '../stores/platforms.js'
|
||||
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||
import ArtistCard from '../components/discovery/ArtistCard.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useArtistDirectoryStore()
|
||||
const platformsStore = usePlatformsStore()
|
||||
|
||||
const search = ref('')
|
||||
const platformModel = ref(null)
|
||||
const sentinelEl = ref(null)
|
||||
|
||||
const platformItems = ref([])
|
||||
|
||||
let debounce = null
|
||||
watch(search, (v) => {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
debounce = setTimeout(() => store.setQuery(v || ''), 300)
|
||||
})
|
||||
// Search term comes from BrowseView's shared sticky bar via route.query.q.
|
||||
const searchTerm = computed(() => (typeof route.query.q === 'string' ? route.query.q : ''))
|
||||
watch(searchTerm, (v) => store.setQuery(v || ''))
|
||||
watch(platformModel, (v) => store.setPlatform(v || null))
|
||||
|
||||
useInfiniteScroll(sentinelEl, () => {
|
||||
@@ -65,7 +61,8 @@ onMounted(async () => {
|
||||
platformItems.value = (platformsStore.platforms || []).map(p => ({
|
||||
title: p.key, value: p.key,
|
||||
}))
|
||||
store.reset()
|
||||
// Apply any deep-linked q (and load). setQuery resets + fetches.
|
||||
store.setQuery(searchTerm.value || '')
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,17 +1,54 @@
|
||||
<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>
|
||||
<!-- Sticky browse chrome: the tab strip + a per-tab search bar, both pinned
|
||||
directly under the 64px TopNav (AppShell). Operator-asked 2026-06-11:
|
||||
the tab nav scrolled away (they didn't know it existed), and there was
|
||||
no search on Posts. Tabs + search live in ONE sticky block so the axis
|
||||
switcher and the search stay reachable no matter how far you scroll.
|
||||
Background uses the theme surface token so content scrolls cleanly
|
||||
under it (matches SettingsView's sticky tabs). -->
|
||||
<div class="fc-browse__head">
|
||||
<v-container fluid class="py-0">
|
||||
<v-tabs v-model="tab" density="compact" color="accent">
|
||||
<v-tab value="posts">Posts</v-tab>
|
||||
<v-tab value="artists">Artists</v-tab>
|
||||
<v-tab value="tags">Tags</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<div class="fc-browse__search">
|
||||
<v-text-field
|
||||
v-model="searchInput"
|
||||
density="compact" variant="outlined" hide-details clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:placeholder="searchPlaceholder"
|
||||
:aria-label="searchPlaceholder"
|
||||
class="fc-browse__search-field"
|
||||
/>
|
||||
<!-- Posts search stays scoped to the active artist/platform filter
|
||||
(operator-asked: 'if an artist is currently filtered in view').
|
||||
The chips surface that scope next to the search even after the
|
||||
PostsFilterBar scrolls off, and clear it to broaden. -->
|
||||
<template v-if="tab === 'posts'">
|
||||
<v-chip
|
||||
v-if="artistId != null" closable size="small"
|
||||
variant="tonal" color="accent" prepend-icon="mdi-account"
|
||||
@click:close="clearFilter('artist_id')"
|
||||
>{{ artistChipLabel }}</v-chip>
|
||||
<v-chip
|
||||
v-if="platform" closable size="small"
|
||||
variant="tonal" color="accent" prepend-icon="mdi-web"
|
||||
@click:close="clearFilter('platform')"
|
||||
>{{ platform }}</v-chip>
|
||||
</template>
|
||||
</div>
|
||||
</v-container>
|
||||
</div>
|
||||
|
||||
<!-- 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.
|
||||
All three tabs read the shared `q` search term from route.query.
|
||||
(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" />
|
||||
@@ -19,17 +56,24 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { usePostsStore } from '../stores/posts.js'
|
||||
import ArtistsView from './ArtistsView.vue'
|
||||
import PostsView from './PostsView.vue'
|
||||
import TagsView from './TagsView.vue'
|
||||
|
||||
const TABS = { posts: PostsView, artists: ArtistsView, tags: TagsView }
|
||||
const PLACEHOLDERS = {
|
||||
posts: 'Search posts (title, description)',
|
||||
artists: 'Search artists',
|
||||
tags: 'Search tags',
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const postsStore = usePostsStore()
|
||||
|
||||
const tab = computed({
|
||||
get() {
|
||||
@@ -38,10 +82,83 @@ const tab = computed({
|
||||
},
|
||||
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.
|
||||
// and the search term belong only to the tab they were typed in and
|
||||
// shouldn't bleed into Artists/Tags.
|
||||
router.replace({ name: 'browse', query: { tab: value } })
|
||||
},
|
||||
})
|
||||
|
||||
const activeComponent = computed(() => TABS[tab.value])
|
||||
const searchPlaceholder = computed(() => PLACEHOLDERS[tab.value])
|
||||
|
||||
// --- search term <-> route.query.q (debounced; deep-linkable) ---------------
|
||||
// One sticky field drives every tab. The term lives in the URL (q=) so it's
|
||||
// shareable and survives reload; each tab view watches route.query.q and
|
||||
// re-queries the server (so the search surfaces content not yet scrolled into
|
||||
// view, not just a client-side filter of loaded rows).
|
||||
const searchInput = ref(typeof route.query.q === 'string' ? route.query.q : '')
|
||||
let debounceTimer = null
|
||||
let syncingFromRoute = false
|
||||
|
||||
watch(searchInput, (v) => {
|
||||
if (syncingFromRoute) return
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
const next = (v || '').trim()
|
||||
const query = { ...route.query }
|
||||
if (next) query.q = next
|
||||
else delete query.q
|
||||
router.replace({ name: 'browse', query })
|
||||
}, 300)
|
||||
})
|
||||
|
||||
// Keep the field in sync when q changes from elsewhere (tab switch clears it,
|
||||
// browser back/forward, a deep link). Guarded so it doesn't re-trigger a route
|
||||
// write.
|
||||
watch(() => route.query.q, (q) => {
|
||||
const val = typeof q === 'string' ? q : ''
|
||||
if (val === searchInput.value) return
|
||||
syncingFromRoute = true
|
||||
searchInput.value = val
|
||||
nextTick(() => { syncingFromRoute = false })
|
||||
})
|
||||
|
||||
// --- active-scope chips (Posts) ---------------------------------------------
|
||||
const artistId = computed(() => {
|
||||
const raw = route.query.artist_id
|
||||
return raw == null ? null : Number(raw)
|
||||
})
|
||||
const platform = computed(() => route.query.platform || null)
|
||||
// Resolve the artist's name from whatever the posts feed has loaded for this
|
||||
// scope; fall back to a generic label when the search returns nothing.
|
||||
const artistChipLabel = computed(() => {
|
||||
const hit = postsStore.items.find((p) => p.artist?.id === artistId.value)
|
||||
return hit?.artist?.name || 'Artist'
|
||||
})
|
||||
|
||||
function clearFilter(key) {
|
||||
const query = { ...route.query }
|
||||
delete query[key]
|
||||
router.replace({ name: 'browse', query })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-browse__head {
|
||||
position: sticky;
|
||||
top: 64px; /* directly under AppShell's 64px sticky TopNav */
|
||||
z-index: 4;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.fc-browse__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 0 12px;
|
||||
}
|
||||
.fc-browse__search-field {
|
||||
max-width: 360px;
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.items.length === 0 && store.done" class="fc-posts__empty">
|
||||
<p>No posts yet. Subscribe to a source on the
|
||||
<p v-if="hasActiveFilter">No posts match your search or filters.</p>
|
||||
<p v-else>No posts yet. Subscribe to a source on the
|
||||
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
|
||||
tab to start capturing posts.
|
||||
</p>
|
||||
@@ -93,10 +94,14 @@ const artistFilter = computed(() => {
|
||||
return raw == null ? null : Number(raw)
|
||||
})
|
||||
const platformFilter = computed(() => route.query.platform || null)
|
||||
const searchFilter = computed(() => route.query.q || null)
|
||||
const postIdFilter = computed(() => {
|
||||
const raw = route.query.post_id
|
||||
return raw == null ? null : Number(raw)
|
||||
})
|
||||
const hasActiveFilter = computed(() =>
|
||||
artistFilter.value != null || platformFilter.value != null || searchFilter.value != null
|
||||
)
|
||||
|
||||
// --- normal feed (downward infinite scroll) ---
|
||||
const sentinel = ref(null)
|
||||
@@ -106,6 +111,7 @@ async function reload() {
|
||||
await store.loadInitial({
|
||||
artist_id: artistFilter.value,
|
||||
platform: platformFilter.value,
|
||||
q: searchFilter.value,
|
||||
})
|
||||
}
|
||||
function onFilters({ artist_id, platform }) {
|
||||
@@ -169,6 +175,7 @@ async function loadAroundAndAnchor() {
|
||||
await store.loadAround(postIdFilter.value, {
|
||||
artist_id: artistFilter.value,
|
||||
platform: platformFilter.value,
|
||||
q: searchFilter.value,
|
||||
})
|
||||
await nextTick()
|
||||
const el = document.getElementById(`fc-post-${store.anchorId}`)
|
||||
@@ -188,7 +195,7 @@ async function activate() {
|
||||
}
|
||||
|
||||
watch(postIdFilter, activate)
|
||||
watch(() => [artistFilter.value, platformFilter.value], async () => {
|
||||
watch(() => [artistFilter.value, platformFilter.value, searchFilter.value], async () => {
|
||||
if (postIdFilter.value != null) return
|
||||
await reload()
|
||||
await nextTick()
|
||||
|
||||
@@ -25,23 +25,22 @@
|
||||
</div>
|
||||
|
||||
<p class="fc-series__hint">
|
||||
One continuous series. <strong>Drag any page</strong> to set its order —
|
||||
the numbers are the page's place in the whole series. Use a page's
|
||||
<strong>⋮ → Start a chapter here</strong> to drop a labeled divider before
|
||||
it. New pages from <strong>Add pages</strong> append to the end, ready to
|
||||
be dragged into place.
|
||||
One continuous series. <strong>Each page's number is yours</strong> — type
|
||||
it on the card; gaps are fine. Drag a page onto a <strong>gap's edge</strong>
|
||||
to give it the adjacent number, or onto the end to append. New pages from a
|
||||
post land in the <strong>pending tray</strong> to sort.
|
||||
</p>
|
||||
|
||||
<!-- Pending tray: pages staged from a post, grouped by source post, for the
|
||||
operator to drop junk and place into the run. -->
|
||||
<!-- Pending tray: pages staged from a post, grouped by source post. Drop the
|
||||
junk, set the start page, then place them numbered from there. -->
|
||||
<div v-if="store.pending.length" class="fc-pending">
|
||||
<div class="fc-pending__head">
|
||||
<v-icon size="small">mdi-tray-arrow-down</v-icon>
|
||||
<span>Pending — sort into the series</span>
|
||||
</div>
|
||||
<p class="fc-pending__hint">
|
||||
New pages from a post land here. Drop the ones that don't belong
|
||||
(text-free alternates, bumpers), then place the rest into the run.
|
||||
Drop the pages that don't belong (text-free alternates, bumpers), then
|
||||
place the rest — they'll be numbered up from the start page you set.
|
||||
</p>
|
||||
<section v-for="(grp, gi) in store.pending" :key="gi" class="fc-pgroup">
|
||||
<header class="fc-pgroup__head">
|
||||
@@ -50,22 +49,26 @@
|
||||
{{ grp.post?.title || 'Unknown post' }}
|
||||
</span>
|
||||
<span class="fc-pgroup__count">{{ grp.pages.length }} pending</span>
|
||||
<label class="fc-pgroup__startfield">
|
||||
from page
|
||||
<input
|
||||
class="fc-pgroup__start" type="number" min="1" placeholder="?"
|
||||
:value="startDraft[gi] ?? grp.start_page ?? ''"
|
||||
@input="startDraft[gi] = $event.target.value"
|
||||
>
|
||||
</label>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
size="small" variant="tonal" color="accent"
|
||||
prepend-icon="mdi-playlist-plus"
|
||||
@click="store.placePending(grp.pages.map(p => p.image_id))"
|
||||
>Place all into series</v-btn>
|
||||
:disabled="effectiveStart(grp, gi) == null"
|
||||
@click="placeGroup(grp, gi)"
|
||||
>Place from page {{ effectiveStart(grp, gi) ?? '?' }}</v-btn>
|
||||
</header>
|
||||
<div class="fc-pgroup__pages">
|
||||
<div v-for="p in grp.pages" :key="p.image_id" class="fc-ppage">
|
||||
<img :src="p.thumbnail_url" alt="" loading="lazy" />
|
||||
<div class="fc-ppage__actions">
|
||||
<v-btn
|
||||
size="x-small" variant="flat" icon="mdi-playlist-plus"
|
||||
title="Place this page into the series"
|
||||
@click="store.placePending([p.image_id])"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small" variant="flat" icon="mdi-close"
|
||||
title="Drop — doesn't belong in the series"
|
||||
@@ -77,66 +80,58 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- One continuous page run; chapter dividers render inline before their
|
||||
anchor page. -->
|
||||
<!-- One continuous run, ordered by each page's number. Chapter dividers and
|
||||
missing-number gap blocks render inline. -->
|
||||
<div v-if="store.pages.length" class="fc-run">
|
||||
<template v-for="(p, pi) in store.pages" :key="p.image_id">
|
||||
<template v-for="p in store.pages" :key="p.image_id">
|
||||
<!-- chapter divider bar, if one is anchored at this page -->
|
||||
<template v-if="store.dividerAt(p.image_id)">
|
||||
<div class="fc-divider">
|
||||
<v-icon size="small" class="fc-divider__icon">mdi-format-section</v-icon>
|
||||
<label class="fc-divider__partfield" title="Part number for this chapter">
|
||||
<span class="fc-divider__partlabel">Part</span>
|
||||
<input
|
||||
class="fc-divider__partnum" type="number" min="1"
|
||||
:value="partDraft[store.dividerAt(p.image_id).id] ?? ''"
|
||||
placeholder="—"
|
||||
@input="partDraft[store.dividerAt(p.image_id).id] = $event.target.value"
|
||||
@keydown.enter.prevent="commitPart(store.dividerAt(p.image_id))"
|
||||
@blur="commitPart(store.dividerAt(p.image_id))"
|
||||
>
|
||||
</label>
|
||||
<v-text-field
|
||||
:model-value="titleDraft[store.dividerAt(p.image_id).id]"
|
||||
placeholder="Untitled chapter"
|
||||
density="compact" variant="plain" hide-details
|
||||
class="fc-divider__title"
|
||||
@update:model-value="titleDraft[store.dividerAt(p.image_id).id] = $event"
|
||||
@keydown.enter="commitTitle(store.dividerAt(p.image_id))"
|
||||
@blur="commitTitle(store.dividerAt(p.image_id))"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
size="x-small" variant="text" icon="mdi-close"
|
||||
title="Remove this chapter divider"
|
||||
@click="store.deleteDivider(store.dividerAt(p.image_id).id)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="store.partGapAfter(store.dividerAt(p.image_id).id)"
|
||||
class="fc-gap"
|
||||
>
|
||||
<v-icon size="x-small">mdi-alert-outline</v-icon>
|
||||
Missing
|
||||
<template v-if="gapText(p.image_id).single">
|
||||
Part {{ gapText(p.image_id).start }}
|
||||
</template>
|
||||
<template v-else>
|
||||
Parts {{ gapText(p.image_id).start }}–{{ gapText(p.image_id).end }}
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="store.dividerAt(p.image_id)" class="fc-divider">
|
||||
<v-icon size="small" class="fc-divider__icon">mdi-format-section</v-icon>
|
||||
<label class="fc-divider__partfield" title="Part number for this chapter">
|
||||
<span class="fc-divider__partlabel">Part</span>
|
||||
<input
|
||||
class="fc-divider__partnum" type="number" min="1"
|
||||
:value="partDraft[store.dividerAt(p.image_id).id] ?? ''"
|
||||
placeholder="—"
|
||||
@input="partDraft[store.dividerAt(p.image_id).id] = $event.target.value"
|
||||
@keydown.enter.prevent="commitPart(store.dividerAt(p.image_id))"
|
||||
@blur="commitPart(store.dividerAt(p.image_id))"
|
||||
>
|
||||
</label>
|
||||
<v-text-field
|
||||
:model-value="titleDraft[store.dividerAt(p.image_id).id]"
|
||||
placeholder="Untitled chapter"
|
||||
density="compact" variant="plain" hide-details
|
||||
class="fc-divider__title"
|
||||
@update:model-value="titleDraft[store.dividerAt(p.image_id).id] = $event"
|
||||
@keydown.enter="commitTitle(store.dividerAt(p.image_id))"
|
||||
@blur="commitTitle(store.dividerAt(p.image_id))"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
size="x-small" variant="text" icon="mdi-close"
|
||||
title="Remove this chapter divider"
|
||||
@click="store.deleteDivider(store.dividerAt(p.image_id).id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- the page -->
|
||||
<div
|
||||
class="fc-page" draggable="true"
|
||||
:class="{ 'fc-page--dragging': drag === pi }"
|
||||
@dragstart="drag = pi"
|
||||
:class="{ 'fc-page--dragging': drag === p.image_id }"
|
||||
@dragstart="drag = p.image_id"
|
||||
@dragend="drag = null"
|
||||
@dragover.prevent
|
||||
@drop="onDrop(pi)"
|
||||
>
|
||||
<span class="fc-page__pn">{{ p.page_number }}</span>
|
||||
<label class="fc-page__numfield" title="Page number (gaps allowed)">
|
||||
<input
|
||||
class="fc-page__num" type="number" min="1"
|
||||
:value="numDraft[p.image_id] ?? ''" placeholder="—"
|
||||
@input="numDraft[p.image_id] = $event.target.value"
|
||||
@keydown.enter.prevent="commitNum(p)"
|
||||
@blur="commitNum(p)"
|
||||
@mousedown.stop
|
||||
>
|
||||
</label>
|
||||
<img :src="p.thumbnail_url" alt="" loading="lazy" />
|
||||
<span
|
||||
v-if="p.source_post?.title" class="fc-page__src"
|
||||
@@ -161,7 +156,29 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- gap block after this page (drop a page on an edge to number it) -->
|
||||
<div
|
||||
v-if="store.gapAfter(p.image_id)" class="fc-gapblock"
|
||||
@dragover.prevent
|
||||
>
|
||||
<div
|
||||
class="fc-gapblock__edge"
|
||||
@drop="onGapDrop(store.gapAfter(p.image_id).from)"
|
||||
>← {{ store.gapAfter(p.image_id).from }}</div>
|
||||
<span class="fc-gapblock__label">{{ gapLabel(store.gapAfter(p.image_id)) }}</span>
|
||||
<div
|
||||
class="fc-gapblock__edge"
|
||||
@drop="onGapDrop(store.gapAfter(p.image_id).to)"
|
||||
>{{ store.gapAfter(p.image_id).to }} →</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- append zone: drop here to give the page the next number -->
|
||||
<div class="fc-append" @dragover.prevent @drop="onAppendDrop">
|
||||
<v-icon size="small">mdi-tray-plus</v-icon>
|
||||
drop here to append (page {{ nextNumber }})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="fc-series__empty">
|
||||
@@ -217,30 +234,37 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
|
||||
import { useSeriesManageStore } from '../stores/seriesManage.js'
|
||||
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||
import TagRenameDialog from '../components/modal/TagRenameDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useSeriesManageStore()
|
||||
const sentinel = ref(null)
|
||||
const drag = ref(null) // index in the flat pages run being dragged
|
||||
const drag = ref(null) // image_id of the page being dragged
|
||||
const numDraft = reactive({}) // image_id -> draft page number (string)
|
||||
const titleDraft = reactive({}) // dividerId -> draft title
|
||||
const partDraft = reactive({}) // dividerId -> draft stated_part (string)
|
||||
const startDraft = reactive({}) // pending group index -> draft start page (string)
|
||||
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 divider drafts in sync with loaded dividers; edits commit on
|
||||
// blur/Enter, which refreshes and resets the draft to the saved value.
|
||||
// Keep card/divider drafts in sync with loaded data; edits commit on blur/Enter,
|
||||
// which refreshes and resets the draft to the saved value.
|
||||
watch(() => store.pages, (ps) => {
|
||||
for (const k of Object.keys(numDraft)) delete numDraft[k]
|
||||
for (const p of ps) {
|
||||
numDraft[p.image_id] = p.page_number == null ? '' : String(p.page_number)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => store.dividers, (divs) => {
|
||||
for (const k of Object.keys(titleDraft)) delete titleDraft[k]
|
||||
for (const k of Object.keys(partDraft)) delete partDraft[k]
|
||||
@@ -250,6 +274,46 @@ watch(() => store.dividers, (divs) => {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Next number = one past the highest assigned page number (1 if none).
|
||||
const nextNumber = computed(() => {
|
||||
let max = 0
|
||||
for (const p of store.pages) {
|
||||
if (p.page_number != null && p.page_number > max) max = p.page_number
|
||||
}
|
||||
return max + 1
|
||||
})
|
||||
|
||||
function commitNum(p) {
|
||||
const raw = (numDraft[p.image_id] ?? '').trim()
|
||||
const next = raw === '' ? null : parseInt(raw, 10)
|
||||
if (raw !== '' && (Number.isNaN(next) || next < 1)) {
|
||||
numDraft[p.image_id] = p.page_number == null ? '' : String(p.page_number)
|
||||
return
|
||||
}
|
||||
if (next === (p.page_number ?? null)) return
|
||||
store.setPageNumber(p.image_id, next)
|
||||
}
|
||||
|
||||
function onGapDrop(number) {
|
||||
if (drag.value == null) return
|
||||
const id = drag.value
|
||||
drag.value = null
|
||||
store.setPageNumber(id, number)
|
||||
}
|
||||
|
||||
function onAppendDrop() {
|
||||
if (drag.value == null) return
|
||||
const id = drag.value
|
||||
drag.value = null
|
||||
store.setPageNumber(id, nextNumber.value)
|
||||
}
|
||||
|
||||
function gapLabel(g) {
|
||||
return g.from === g.to
|
||||
? `page ${g.from} missing`
|
||||
: `pages ${g.from}–${g.to} missing`
|
||||
}
|
||||
|
||||
function commitTitle(d) {
|
||||
const v = (titleDraft[d.id] || '').trim()
|
||||
if (v === (d.title || '')) return
|
||||
@@ -267,19 +331,21 @@ function commitPart(d) {
|
||||
store.setDividerPart(d.id, next)
|
||||
}
|
||||
|
||||
function gapText(anchorImageId) {
|
||||
const d = store.dividerAt(anchorImageId)
|
||||
const g = d ? store.partGapAfter(d.id) : null
|
||||
return { start: g?.start, end: g?.end, single: g && g.start === g.end }
|
||||
// Pending: effective start page = the typed override, else the post's parsed
|
||||
// start. null when neither is set (operator must type one).
|
||||
function effectiveStart(grp, gi) {
|
||||
const raw = (startDraft[gi] ?? '').toString().trim()
|
||||
if (raw !== '') {
|
||||
const n = parseInt(raw, 10)
|
||||
return Number.isNaN(n) || n < 1 ? null : n
|
||||
}
|
||||
return grp.start_page ?? null
|
||||
}
|
||||
|
||||
function onDrop(toIdx) {
|
||||
if (drag.value === null || drag.value === toIdx) { drag.value = null; return }
|
||||
const ordered = moveItem(
|
||||
store.pages.map(p => p.image_id), drag.value, toIdx
|
||||
)
|
||||
drag.value = null
|
||||
store.reorder(ordered)
|
||||
function placeGroup(grp, gi) {
|
||||
const start = effectiveStart(grp, gi)
|
||||
if (start == null) return
|
||||
store.placePending(grp.pages.map(p => p.image_id), start)
|
||||
}
|
||||
|
||||
useInfiniteScroll(sentinel, () => store.loadPicker())
|
||||
@@ -303,7 +369,7 @@ onMounted(async () => {
|
||||
margin-bottom: 18px; max-width: 820px;
|
||||
}
|
||||
|
||||
/* One continuous page run with inline divider bars. */
|
||||
/* One continuous run with inline dividers + gap blocks. */
|
||||
.fc-run {
|
||||
display: grid; gap: 10px; max-width: 1100px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
@@ -339,7 +405,38 @@ onMounted(async () => {
|
||||
}
|
||||
.fc-divider__title { flex: 1 1 200px; min-width: 120px; font-size: 15px; }
|
||||
|
||||
/* Big page tiles */
|
||||
/* Gap block — one per gap; drop a page on an edge to number it. */
|
||||
.fc-gapblock {
|
||||
grid-column: 1 / -1;
|
||||
display: flex; align-items: stretch; gap: 8px;
|
||||
padding: 4px; border: 1px dashed rgb(var(--v-theme-on-surface-variant), 0.5);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.fc-gapblock__edge {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-width: 80px; padding: 6px 10px; border-radius: 6px;
|
||||
font-variant-numeric: tabular-nums; font-size: 13px;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
color: rgb(var(--v-theme-on-surface-variant)); cursor: copy;
|
||||
}
|
||||
.fc-gapblock__edge:hover {
|
||||
background: rgb(var(--v-theme-accent), 0.2);
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-gapblock__label {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
|
||||
.fc-append {
|
||||
grid-column: 1 / -1;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
padding: 12px; border: 1px dashed rgb(var(--v-theme-on-surface-variant), 0.4);
|
||||
border-radius: 8px; font-size: 13px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
|
||||
/* Page tiles */
|
||||
.fc-page {
|
||||
position: relative; border-radius: 8px; overflow: hidden; cursor: grab;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
@@ -350,13 +447,19 @@ onMounted(async () => {
|
||||
width: 100%; aspect-ratio: 3 / 4; object-fit: contain;
|
||||
display: block; background: rgb(var(--v-theme-background));
|
||||
}
|
||||
.fc-page__pn {
|
||||
.fc-page__numfield {
|
||||
position: absolute; top: 6px; left: 6px; z-index: 2;
|
||||
min-width: 24px; height: 24px; padding: 0 6px; border-radius: 12px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums;
|
||||
background: rgb(var(--v-theme-accent)); color: rgb(var(--v-theme-on-accent, 0 0 0));
|
||||
background: rgb(var(--v-theme-accent)); border-radius: 12px;
|
||||
padding: 0 4px; height: 24px; display: flex; align-items: center;
|
||||
}
|
||||
.fc-page__num {
|
||||
width: 34px; height: 22px; text-align: center;
|
||||
font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums;
|
||||
background: transparent; border: none;
|
||||
color: rgb(var(--v-theme-on-accent, 0 0 0));
|
||||
}
|
||||
.fc-page__num:focus { outline: none; }
|
||||
.fc-page__num::placeholder { color: rgb(var(--v-theme-on-accent, 0 0 0)); opacity: 0.5; }
|
||||
.fc-page__src {
|
||||
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
|
||||
display: flex; align-items: center; gap: 4px; padding: 3px 6px;
|
||||
@@ -372,11 +475,6 @@ onMounted(async () => {
|
||||
background: rgba(0, 0, 0, 0.55); color: #fff;
|
||||
}
|
||||
|
||||
.fc-gap {
|
||||
grid-column: 1 / -1;
|
||||
display: flex; align-items: center; gap: 6px; padding: 2px 10px;
|
||||
font-size: 12px; color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
|
||||
}
|
||||
.fc-series__empty {
|
||||
padding: 40px; text-align: center; color: rgb(var(--v-theme-on-surface-variant));
|
||||
max-width: 1100px;
|
||||
@@ -401,15 +499,27 @@ onMounted(async () => {
|
||||
.fc-pgroup { margin-bottom: 12px; }
|
||||
.fc-pgroup__head {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fc-pgroup__title {
|
||||
display: inline-flex; align-items: center; gap: 4px; max-width: 360px;
|
||||
display: inline-flex; align-items: center; gap: 4px; max-width: 320px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-size: 13px; font-weight: 600;
|
||||
}
|
||||
.fc-pgroup__count {
|
||||
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-pgroup__startfield {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-pgroup__start {
|
||||
width: 56px; text-align: center; font-size: 13px;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
border: 1px solid transparent; border-radius: 4px; padding: 2px 4px;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-pgroup__start:focus { outline: none; border-color: rgb(var(--v-theme-accent)); }
|
||||
.fc-pgroup__pages {
|
||||
display: grid; gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
|
||||
@@ -330,6 +330,7 @@ onMounted(() => {
|
||||
.fc-sbcard__kebab {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
background: rgba(20, 23, 26, 0.6) !important;
|
||||
border-radius: 50%;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-sbcard__kebab:hover { background: rgba(20, 23, 26, 0.85) !important; }
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
<template>
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
|
||||
<!-- Search lives in BrowseView's sticky bar (shared across tabs); this view
|
||||
reacts to route.query.q. The kind filter stays here. -->
|
||||
<div class="fc-tags__controls">
|
||||
<v-text-field
|
||||
v-model="search" density="compact" variant="outlined"
|
||||
prepend-inner-icon="mdi-magnify" hide-details
|
||||
placeholder="Search tags" clearable style="max-width: 320px;"
|
||||
/>
|
||||
<v-chip-group
|
||||
v-model="kind" selected-class="text-accent" :mandatory="false"
|
||||
>
|
||||
@@ -103,8 +100,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTagDirectoryStore } from '../stores/tagDirectory.js'
|
||||
import { useAdminStore } from '../stores/admin.js'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
@@ -122,9 +119,9 @@ import FandomSetDialog from '../components/modal/FandomSetDialog.vue'
|
||||
// a tag. 'meta' + 'rating' retired by operator 2026-05-26 (alembic 0023).
|
||||
const KINDS = ['character', 'fandom', 'general', 'series']
|
||||
const store = useTagDirectoryStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const search = ref('')
|
||||
const kind = ref(null)
|
||||
const sentinelEl = ref(null)
|
||||
const pendingMerge = ref(null)
|
||||
@@ -140,17 +137,16 @@ async function confirmMerge() {
|
||||
if (c) await store.merge(c.sourceId, c.target.id)
|
||||
}
|
||||
|
||||
let debounce = null
|
||||
watch(search, (v) => {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
debounce = setTimeout(() => store.setQuery(v || ''), 300)
|
||||
})
|
||||
// Search term comes from BrowseView's shared sticky bar via route.query.q.
|
||||
const searchTerm = computed(() => (typeof route.query.q === 'string' ? route.query.q : ''))
|
||||
watch(searchTerm, (v) => store.setQuery(v || ''))
|
||||
watch(kind, (v) => store.setKind(v || null))
|
||||
|
||||
useInfiniteScroll(sentinelEl, () => {
|
||||
if (store.hasMore && !store.loading) store.loadMore()
|
||||
})
|
||||
onMounted(() => store.reset())
|
||||
// Apply any deep-linked q (and load). setQuery resets + fetches.
|
||||
onMounted(() => store.setQuery(searchTerm.value || ''))
|
||||
|
||||
function openTag(tagId) {
|
||||
router.push({ name: 'gallery', query: { tag_id: tagId } })
|
||||
|
||||
@@ -42,19 +42,30 @@ describe('seriesManage', () => {
|
||||
expect(s.pageCount).toBe(1)
|
||||
})
|
||||
|
||||
it('reorder posts ordered ids to the series reorder route', async () => {
|
||||
it('setPageNumber posts the image + number to /pages/number', async () => {
|
||||
const s = useSeriesManageStore()
|
||||
s.tagId = 7
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, body: init.body ? JSON.parse(init.body) : null })
|
||||
if (url.includes('/reorder')) return { status: 200, body: { ok: true } }
|
||||
if (url.includes('/pages/number')) return { status: 200, body: { ok: true } }
|
||||
return { status: 200, body: SERIES_BODY }
|
||||
})
|
||||
await s.reorder([30, 10, 20])
|
||||
const r = calls.find(c => c.url.includes('/reorder'))
|
||||
expect(r.url).toContain('/api/series/7/reorder')
|
||||
expect(r.body).toEqual({ image_ids: [30, 10, 20] })
|
||||
await s.setPageNumber(42, 5)
|
||||
const r = calls.find(c => c.url.includes('/pages/number'))
|
||||
expect(r.url).toContain('/api/series/7/pages/number')
|
||||
expect(r.body).toEqual({ image_id: 42, page_number: 5 })
|
||||
})
|
||||
|
||||
it('gapAfter looks up a gap by its after_image_id', async () => {
|
||||
const s = useSeriesManageStore()
|
||||
stubFetch(() => ({
|
||||
status: 200,
|
||||
body: { ...SERIES_BODY, gaps: [{ after_image_id: 1, before_image_id: 9, from: 2, to: 3 }] }
|
||||
}))
|
||||
await s.load(7)
|
||||
expect(s.gapAfter(1)).toEqual({ after_image_id: 1, before_image_id: 9, from: 2, to: 3 })
|
||||
expect(s.gapAfter(99)).toBeNull()
|
||||
})
|
||||
|
||||
it('createDivider posts the anchor image + labels', async () => {
|
||||
|
||||
@@ -88,6 +88,16 @@ async def test_list_filter_propagates_artist(client, seeded_post):
|
||||
assert body["items"][0]["id"] == post.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_text_search_propagates(client, seeded_post):
|
||||
_, _, post = seeded_post # title "Hello", description "<p>hi</p>"
|
||||
hit = await client.get("/api/posts?q=hello")
|
||||
assert hit.status_code == 200
|
||||
assert [it["id"] for it in (await hit.get_json())["items"]] == [post.id]
|
||||
miss = await client.get("/api/posts?q=zzznope")
|
||||
assert (await miss.get_json())["items"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_200_for_known(client, seeded_post):
|
||||
_, _, post = seeded_post
|
||||
|
||||
@@ -44,10 +44,10 @@ async def test_add_over_cap_400(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reorder_mismatch_400(client):
|
||||
sid = await _series(client, "Re")
|
||||
async def test_set_page_number_requires_image_id(client):
|
||||
sid = await _series(client, "Num")
|
||||
resp = await client.post(
|
||||
f"/api/series/{sid}/reorder", json={"image_ids": [1, 2, 3]}
|
||||
f"/api/series/{sid}/pages/number", json={"page_number": 5}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@@ -180,6 +180,54 @@ async def test_scroll_filters_by_platform(db):
|
||||
assert [it["id"] for it in page["items"]] == [pp.id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_text_search_matches_title_or_description(db):
|
||||
artist = await _seed_artist(db, "alice-search")
|
||||
src = await _seed_source(db, artist.id, "patreon", "https://p/alice-se")
|
||||
now = datetime.now(UTC)
|
||||
by_title = await _seed_post(
|
||||
db, src.id, external_id="T", post_date=now,
|
||||
title="Dragon Quest cover", description="<p>nothing</p>",
|
||||
)
|
||||
by_desc = await _seed_post(
|
||||
db, src.id, external_id="D", post_date=now - timedelta(days=1),
|
||||
title="Untitled", description="<p>a sleeping dragon</p>",
|
||||
)
|
||||
await _seed_post(
|
||||
db, src.id, external_id="N", post_date=now - timedelta(days=2),
|
||||
title="Cat nap", description="<p>just a cat</p>",
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
page = await PostFeedService(db).scroll(cursor=None, limit=10, q="dragon")
|
||||
ids = {it["id"] for it in page["items"]}
|
||||
# ILIKE is case-insensitive and spans title OR description; the cat post
|
||||
# matches neither.
|
||||
assert ids == {by_title.id, by_desc.id}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_text_search_stays_scoped_to_artist(db):
|
||||
alice = await _seed_artist(db, "alice-scope")
|
||||
bob = await _seed_artist(db, "bob-scope")
|
||||
src_a = await _seed_source(db, alice.id, "patreon", "https://p/alice-sc")
|
||||
src_b = await _seed_source(db, bob.id, "patreon", "https://p/bob-sc")
|
||||
now = datetime.now(UTC)
|
||||
alice_hit = await _seed_post(
|
||||
db, src_a.id, external_id="AH", post_date=now, title="comet study",
|
||||
)
|
||||
# Same query word, different artist — must be excluded by the scope.
|
||||
await _seed_post(
|
||||
db, src_b.id, external_id="BH", post_date=now, title="comet sketch",
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
page = await PostFeedService(db).scroll(
|
||||
cursor=None, limit=10, q="comet", artist_id=alice.id,
|
||||
)
|
||||
assert [it["id"] for it in page["items"]] == [alice_hit.id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scroll_combined_artist_and_platform(db):
|
||||
alice = await _seed_artist(db, "alice-combo")
|
||||
|
||||
@@ -93,7 +93,9 @@ async def test_add_post_stages_pending(db):
|
||||
assert grp["post"]["id"] == post.id
|
||||
assert grp["post"]["title"] == "Story pages 9-11"
|
||||
assert [pg["image_id"] for pg in grp["pages"]] == imgs
|
||||
assert [pg["stated_page"] for pg in grp["pages"]] == [9, 10, 11]
|
||||
# add_post stamps the post's parsed START (9) on every staged page; the
|
||||
# group surfaces it as start_page (the default for "Place from page N").
|
||||
assert grp["start_page"] == 9
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -76,25 +76,29 @@ async def test_place_pending_appends_and_flips_status(db):
|
||||
imgs = await _post_images(db, post, artist, 3)
|
||||
await svc.add_post(sid, post.id)
|
||||
|
||||
placed = await svc.place_pending(sid, imgs)
|
||||
placed = await svc.place_pending(sid, imgs, start_page=3)
|
||||
assert placed == 3
|
||||
assert await _placed_order(db, sid) == seed + imgs
|
||||
# nothing left pending; page numbers compact 1..5
|
||||
# nothing left pending; the placed pages are numbered 1,2 (seed) + 3,4,5
|
||||
data = await svc.list_pages(sid)
|
||||
assert data["pending"] == []
|
||||
assert [p["page_number"] for p in data["pages"]] == [1, 2, 3, 4, 5]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_place_pending_before_a_placed_page(db):
|
||||
svc, artist, sid, seed = await _setup(db, "PB")
|
||||
a, b = seed
|
||||
async def test_place_pending_numbers_from_start(db):
|
||||
svc, artist, sid, seed = await _setup(db, "PB") # seed numbered 1, 2
|
||||
post = await _post(db, artist, "PB pages", "pb-p")
|
||||
x, y = await _post_images(db, post, artist, 2)
|
||||
await svc.add_post(sid, post.id)
|
||||
|
||||
await svc.place_pending(sid, [x, y], before_image_id=b)
|
||||
assert await _placed_order(db, sid) == [a, x, y, b]
|
||||
await svc.place_pending(sid, [x, y], start_page=10)
|
||||
data = await svc.list_pages(sid)
|
||||
nums = {p["image_id"]: p["page_number"] for p in data["pages"]}
|
||||
assert nums[x] == 10
|
||||
assert nums[y] == 11
|
||||
# order = seed (1, 2) then x (10), y (11)
|
||||
assert [p["image_id"] for p in data["pages"]] == seed + [x, y]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -84,17 +84,24 @@ async def test_remove(db):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reorder_rewrites_and_rejects_mismatch(db):
|
||||
async def test_set_page_number_sparse_with_gaps(db):
|
||||
s = await TagService(db).find_or_create("S4", TagKind.series)
|
||||
i1, i2, i3 = await _img(db), await _img(db), await _img(db)
|
||||
svc = SeriesService(db)
|
||||
await svc.add_images(s.id, [i1, i2, i3])
|
||||
await svc.reorder(s.id, [i3, i1, i2])
|
||||
assert await _order(db, s.id) == [i3, i1, i2]
|
||||
await svc.add_images(s.id, [i1, i2, i3]) # numbered 1, 2, 3
|
||||
# set a sparse number, leaving a gap
|
||||
await svc.set_page_number(s.id, i3, 5)
|
||||
out = await svc.list_pages(s.id)
|
||||
assert [p["page_number"] for p in out["pages"]] == [1, 2, 5]
|
||||
assert len(out["gaps"]) == 1
|
||||
assert out["gaps"][0]["from"] == 3
|
||||
assert out["gaps"][0]["to"] == 4
|
||||
# unnumber a page → it sorts to the tail
|
||||
await svc.set_page_number(s.id, i1, None)
|
||||
out = await svc.list_pages(s.id)
|
||||
assert [p["page_number"] for p in out["pages"]] == [2, 5, None]
|
||||
with pytest.raises(SeriesError):
|
||||
await svc.reorder(s.id, [i1, i2])
|
||||
with pytest.raises(SeriesError):
|
||||
await svc.reorder(s.id, [i1, i2, i3, 99999])
|
||||
await svc.set_page_number(s.id, 99999, 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user