feat(series): manage-view redesign — big pages, editable Part #, slide-over picker (FC-6.4)
Operator feedback: thumbnails too small to judge order, no obvious way to mark
'this installment is Part 2', and the permanent two-pane picker was busy and
competed with the ordering work.
- Full-width parts, each a card with a big page grid (150px, contain so whole
pages are visible) and drag-to-reorder; positional page number as a badge.
- Editable Part # (hero field) backed by new series_chapter.stated_part —
separate from the auto-managed chapter_number, mirroring the page_number vs
stated_page split so reorder/delete renumbering can't wipe a hand-set part.
Missing-Part hints when consecutive parts' stated_part jump >1.
- Each part labels its source post (derived from pages' primary_post_id) and
shows the printed-page range with clear labels.
- Picker demoted to an on-demand right slide-over ('Add pages') with a target-
part selector; part actions (move/merge/delete) collapsed into an overflow ⋮.
alembic 0042 adds series_chapter.stated_part (nullable int).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -528,6 +528,11 @@ async def series_chapter_update(tag_id: int, chapter_id: int):
|
||||
if body["title"] is not None and not isinstance(body["title"], str):
|
||||
return jsonify({"error": "title must be a string"}), 400
|
||||
kwargs.update(set_title=True, title=body["title"])
|
||||
if "stated_part" in body:
|
||||
part, perr = _opt_int(body, "stated_part")
|
||||
if perr:
|
||||
return perr
|
||||
kwargs.update(set_part=True, stated_part=part)
|
||||
if "stated_page_start" in body:
|
||||
start, serr = _opt_int(body, "stated_page_start")
|
||||
if serr:
|
||||
|
||||
@@ -12,6 +12,12 @@ A chapter may be a placeholder (is_placeholder=True) — a reserved empty slot f
|
||||
a section the operator doesn't have yet; it holds no pages and shows as a gap in
|
||||
the reader. stated_page_start/end carry the page range parsed from the source
|
||||
post (FC-6.2), used to flag missing-page gaps; both are nullable when unknown.
|
||||
|
||||
stated_part is the operator-facing "Part N" label (FC-6.4), separate from the
|
||||
positional chapter_number: chapter_number is auto-managed ordering (rewritten
|
||||
1..N on reorder/delete), while stated_part is the real installment number the
|
||||
operator types — e.g. a series authored from a post that is Part 2 of a story.
|
||||
Nullable when unset (the UI then falls back to showing chapter_number).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
@@ -30,6 +36,7 @@ class SeriesChapter(Base):
|
||||
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
chapter_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
stated_part: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_placeholder: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, server_default="false"
|
||||
|
||||
@@ -131,6 +131,29 @@ class SeriesService:
|
||||
prev = ch
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _part_gaps(chapters: list[dict]) -> list[dict]:
|
||||
"""Missing-Part gaps between consecutive chapters whose stated_part
|
||||
numbers jump by more than 1 (e.g. a series with Part 1 and Part 3, or one
|
||||
authored straight from a Part 2 post). Mirrors _gaps but on stated_part —
|
||||
only chapters that actually carry a stated_part participate."""
|
||||
out: list[dict] = []
|
||||
prev = None
|
||||
for ch in chapters:
|
||||
cur = ch["stated_part"]
|
||||
if cur is None:
|
||||
continue
|
||||
if prev is not None and cur > prev["stated_part"] + 1:
|
||||
out.append(
|
||||
{
|
||||
"after_chapter_id": prev["id"],
|
||||
"start": prev["stated_part"] + 1,
|
||||
"end": cur - 1,
|
||||
}
|
||||
)
|
||||
prev = ch
|
||||
return out
|
||||
|
||||
async def list_pages(self, series_tag_id: int) -> dict:
|
||||
tag = await self._require_series(series_tag_id)
|
||||
rows = (
|
||||
@@ -138,6 +161,7 @@ class SeriesService:
|
||||
select(
|
||||
SeriesChapter.id.label("chapter_id"),
|
||||
SeriesChapter.chapter_number,
|
||||
SeriesChapter.stated_part,
|
||||
SeriesChapter.title,
|
||||
SeriesChapter.is_placeholder,
|
||||
SeriesChapter.stated_page_start,
|
||||
@@ -149,10 +173,13 @@ class SeriesService:
|
||||
ImageRecord.mime,
|
||||
ImageRecord.path,
|
||||
ImageRecord.thumbnail_path,
|
||||
ImageRecord.primary_post_id,
|
||||
Post.post_title,
|
||||
)
|
||||
.select_from(SeriesChapter)
|
||||
.outerjoin(SeriesPage, SeriesPage.chapter_id == SeriesChapter.id)
|
||||
.outerjoin(ImageRecord, ImageRecord.id == SeriesPage.image_id)
|
||||
.outerjoin(Post, Post.id == ImageRecord.primary_post_id)
|
||||
.where(SeriesChapter.series_tag_id == series_tag_id)
|
||||
.order_by(
|
||||
SeriesChapter.chapter_number.asc(),
|
||||
@@ -164,22 +191,30 @@ class SeriesService:
|
||||
chapters: list[dict] = []
|
||||
flat: list[dict] = []
|
||||
by_id: dict[int, dict] = {}
|
||||
# chapter_id -> {post_id: title} seen across its pages, so we can label a
|
||||
# chapter with its source post when all its pages come from one post.
|
||||
posts_seen: dict[int, dict[int, str | None]] = {}
|
||||
for r in rows:
|
||||
ch = by_id.get(r.chapter_id)
|
||||
if ch is None:
|
||||
ch = {
|
||||
"id": r.chapter_id,
|
||||
"chapter_number": r.chapter_number,
|
||||
"stated_part": r.stated_part,
|
||||
"title": r.title,
|
||||
"is_placeholder": r.is_placeholder,
|
||||
"stated_page_start": r.stated_page_start,
|
||||
"stated_page_end": r.stated_page_end,
|
||||
"source_post": None,
|
||||
"pages": [],
|
||||
}
|
||||
by_id[r.chapter_id] = ch
|
||||
posts_seen[r.chapter_id] = {}
|
||||
chapters.append(ch)
|
||||
if r.image_id is None:
|
||||
continue # placeholder / empty chapter
|
||||
if r.primary_post_id is not None:
|
||||
posts_seen[r.chapter_id][r.primary_post_id] = r.post_title
|
||||
page = {
|
||||
"image_id": r.image_id,
|
||||
"chapter_id": r.chapter_id,
|
||||
@@ -191,11 +226,21 @@ class SeriesService:
|
||||
ch["pages"].append(page)
|
||||
flat.append(page)
|
||||
|
||||
# A chapter's source_post is set only when every page shares one post —
|
||||
# the common case (a series authored from a post). Mixed chapters stay
|
||||
# null rather than guessing.
|
||||
for ch in chapters:
|
||||
seen = posts_seen.get(ch["id"], {})
|
||||
if len(seen) == 1:
|
||||
pid, title = next(iter(seen.items()))
|
||||
ch["source_post"] = {"id": pid, "title": title}
|
||||
|
||||
return {
|
||||
"series": {"id": tag.id, "name": tag.name},
|
||||
"chapters": chapters,
|
||||
"pages": flat, # back-compat: flat reading order across chapters
|
||||
"gaps": self._gaps(chapters),
|
||||
"part_gaps": self._part_gaps(chapters),
|
||||
}
|
||||
|
||||
# ---- pages ------------------------------------------------------------
|
||||
@@ -353,19 +398,23 @@ class SeriesService:
|
||||
chapter_id: int,
|
||||
*,
|
||||
title: str | None = None,
|
||||
stated_part: int | None = None,
|
||||
stated_page_start: int | None = None,
|
||||
stated_page_end: int | None = None,
|
||||
set_title: bool = False,
|
||||
set_part: bool = False,
|
||||
set_start: bool = False,
|
||||
set_end: bool = False,
|
||||
) -> None:
|
||||
"""Partial chapter edit. The set_* flags say which fields to write (so
|
||||
None can be written explicitly, e.g. clearing a stated page)."""
|
||||
None can be written explicitly, e.g. clearing a stated page or part)."""
|
||||
await self._require_series(series_tag_id)
|
||||
await self._require_chapter(series_tag_id, chapter_id)
|
||||
values: dict = {}
|
||||
if set_title:
|
||||
values["title"] = title
|
||||
if set_part:
|
||||
values["stated_part"] = stated_part
|
||||
if set_start:
|
||||
values["stated_page_start"] = stated_page_start
|
||||
if set_end:
|
||||
|
||||
Reference in New Issue
Block a user