Merge pull request 'Series manage redesign (FC-6.4) + migration/normalize hardening + UX fixes' (#84) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 1m58s
Build images / build-ml (push) Successful in 2m28s
CI / integration (push) Successful in 3m6s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 1m58s
Build images / build-ml (push) Successful in 2m28s
CI / integration (push) Successful in 3m6s
This commit was merged in pull request #84.
This commit is contained in:
@@ -69,3 +69,4 @@ Thumbs.db
|
||||
alembic/versions/__pycache__/
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
.superpowers/
|
||||
|
||||
+22
-1
@@ -2,12 +2,21 @@
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlalchemy import engine_from_config, pool, text
|
||||
|
||||
from alembic import context
|
||||
from backend.app.config import get_config
|
||||
from backend.app.models import Base
|
||||
|
||||
# Arbitrary fixed 64-bit key for the session/transaction advisory lock that
|
||||
# serializes concurrent `alembic upgrade head` runs. Every `web` replica runs
|
||||
# migrations in its entrypoint, so under `docker stack deploy` two replicas can
|
||||
# boot at once and race the same DDL — duplicate CREATE TABLE, then a crashed
|
||||
# replica (operator-flagged 2026-06-07: 0040 raced; one backend died with
|
||||
# AdminShutdown). The first replica to reach the lock migrates; the rest block,
|
||||
# then find the version table already at head and apply nothing.
|
||||
_MIGRATION_LOCK_KEY = 0xFCA1E35C
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
@@ -44,6 +53,18 @@ def run_migrations_online() -> None:
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
# Serialize concurrent migrators (see _MIGRATION_LOCK_KEY). A
|
||||
# transaction-scoped advisory lock: the first replica to get here
|
||||
# holds it for the whole upgrade and is auto-released when this
|
||||
# transaction ends. A sibling replica blocks on this line, and only
|
||||
# once the leader commits does it proceed to read the version table
|
||||
# — now at head — so it runs zero migrations instead of re-applying
|
||||
# the same DDL. The lock is acquired BEFORE run_migrations() reads
|
||||
# the current revision, which is what makes the no-op correct.
|
||||
connection.execute(
|
||||
text("SELECT pg_advisory_xact_lock(:k)"),
|
||||
{"k": _MIGRATION_LOCK_KEY},
|
||||
)
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""series chapter stated_part: operator-facing Part N label (FC-6.4)
|
||||
|
||||
Revision ID: 0042
|
||||
Revises: 0041
|
||||
Create Date: 2026-06-07
|
||||
|
||||
A chapter's positional chapter_number is auto-managed (rewritten 1..N on
|
||||
reorder/delete), so it can't double as the installment number the operator wants
|
||||
to type (e.g. a series authored from a post that is Part 2). Add a nullable
|
||||
stated_part alongside it — the same split as series_page.page_number (order) vs
|
||||
series_page.stated_page (printed number). Nullable; the UI falls back to
|
||||
chapter_number when unset.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0042"
|
||||
down_revision: Union[str, None] = "0041"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"series_chapter", sa.Column("stated_part", sa.Integer, nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("series_chapter", "stated_part")
|
||||
@@ -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"
|
||||
|
||||
@@ -22,7 +22,11 @@ class TagAllowlist(Base):
|
||||
tag_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
min_confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.95)
|
||||
# Default auto-apply threshold for a newly-accepted tag. 0.90 (lowered from
|
||||
# 0.95 on operator evidence 2026-06-07: 0.95 was too strict and skipped
|
||||
# confident-enough applications). Per-tag value is still tunable in the
|
||||
# allowlist table; existing rows keep whatever they were stored with.
|
||||
min_confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.90)
|
||||
added_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -743,6 +743,10 @@ async def normalize_existing_tags(
|
||||
"sample": sample,
|
||||
}
|
||||
start = time.monotonic()
|
||||
log.info(
|
||||
"normalize_existing_tags: %d group(s) need changes (budget=%ss)",
|
||||
len(touched), time_budget_seconds,
|
||||
)
|
||||
for done, (key, members) in enumerate(touched):
|
||||
# Time-box: stop cleanly before the Celery limit kills us mid-group and
|
||||
# strands the run as a timeout. The caller re-enqueues to finish the
|
||||
@@ -754,6 +758,16 @@ async def normalize_existing_tags(
|
||||
summary["partial"] = True
|
||||
summary["remaining"] = len(touched) - done
|
||||
break
|
||||
# Heartbeat so a long run is diagnosable instead of silent — the timeout
|
||||
# operator-flagged 2026-06-07 produced zero logs because the only log was
|
||||
# per finished group and it was stuck mid-group on a lock.
|
||||
if done and done % 25 == 0:
|
||||
log.info(
|
||||
"normalize_existing_tags: %d/%d groups (%d merged, %d errors, "
|
||||
"%.0fs elapsed)",
|
||||
done, len(touched), summary["merged"], summary["errors"],
|
||||
time.monotonic() - start,
|
||||
)
|
||||
canonical = key[2]
|
||||
names_by_id = dict(members)
|
||||
# Survivor: prefer a member already named canonically (no rename, no
|
||||
|
||||
@@ -15,8 +15,15 @@ from sqlalchemy.pool import NullPool
|
||||
from ..config import get_config
|
||||
|
||||
|
||||
def async_session_factory():
|
||||
"""Return ``(sessionmaker, engine)`` bound to a fresh async engine."""
|
||||
def async_session_factory(*, server_settings: dict | None = None):
|
||||
"""Return ``(sessionmaker, engine)`` bound to a fresh async engine.
|
||||
|
||||
``server_settings`` (optional) are applied by asyncpg as per-connection GUCs
|
||||
on connect. Because NullPool opens a fresh real connection per checkout, every
|
||||
transaction in the task inherits them — used to set ``lock_timeout`` so a
|
||||
statement blocked on a lock fails fast instead of hanging to the Celery hard
|
||||
limit (operator-flagged normalize_tags timeout 2026-06-07).
|
||||
"""
|
||||
cfg = get_config()
|
||||
# NullPool: this engine lives for ONE task (created + disposed per
|
||||
# asyncio.run loop), so intra-task connection pooling buys nothing and
|
||||
@@ -26,5 +33,13 @@ def async_session_factory():
|
||||
# phase 3 (asyncpg ConnectionDoesNotExistError, Anduo #40014). NullPool
|
||||
# opens a fresh real connection on each checkout, so phase 3 always
|
||||
# reconnects clean; pre_ping is then redundant.
|
||||
engine = create_async_engine(cfg.database_url, future=True, poolclass=NullPool)
|
||||
connect_args = {}
|
||||
if server_settings:
|
||||
connect_args["server_settings"] = {
|
||||
k: str(v) for k, v in server_settings.items()
|
||||
}
|
||||
engine = create_async_engine(
|
||||
cfg.database_url, future=True, poolclass=NullPool,
|
||||
connect_args=connect_args,
|
||||
)
|
||||
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine
|
||||
|
||||
@@ -126,7 +126,16 @@ def normalize_tags_task(self) -> dict:
|
||||
from ._async_session import async_session_factory
|
||||
|
||||
async def _run() -> dict:
|
||||
async_factory, async_engine = async_session_factory()
|
||||
# lock_timeout=30s: a per-group merge repoints FKs across image_tag and
|
||||
# series_page; if a statement blocks on a lock (e.g. behind a schema
|
||||
# migration holding ACCESS EXCLUSIVE on series_page — the exact wedge that
|
||||
# made this task run to the 40-min hard limit with no progress,
|
||||
# operator-flagged 2026-06-07), it now fails fast. The per-group handler
|
||||
# catches it (rollback + error++) and the loop continues, so one blocked
|
||||
# group can't strand the whole chunk.
|
||||
async_factory, async_engine = async_session_factory(
|
||||
server_settings={"lock_timeout": "30s"}
|
||||
)
|
||||
try:
|
||||
async with async_factory() as session:
|
||||
# normalize_existing_tags commits per group internally.
|
||||
|
||||
@@ -3,20 +3,29 @@
|
||||
<v-card-title class="text-body-1">Fandom for “{{ tag.name }}”</v-card-title>
|
||||
<v-card-text>
|
||||
<template v-if="!collision">
|
||||
<!-- Keyboard flow matches FandomPicker (operator-specified 2026-06-07):
|
||||
focus lands here via the parent dialog's @after-enter → focusSearch
|
||||
(autofocus is unreliable inside a v-dialog focus-trap); Tab moves to
|
||||
the new-fandom field; a SECOND Enter (menu closed) accepts the choice
|
||||
by Saving instead of re-opening the dropdown. -->
|
||||
<v-autocomplete
|
||||
ref="fandomRef"
|
||||
v-model="selectedId"
|
||||
v-model:menu="menuOpen"
|
||||
:items="store.fandomCache"
|
||||
:item-title="(f) => f.name" :item-value="(f) => f.id"
|
||||
label="Fandom" clearable density="compact"
|
||||
autofocus
|
||||
:hint="selectedId == null
|
||||
? 'No fandom — the character will be unassigned.' : ''"
|
||||
persistent-hint
|
||||
@keydown.enter.capture="onSearchEnter"
|
||||
@keydown.tab="onSearchTab"
|
||||
/>
|
||||
<v-divider class="my-3" />
|
||||
<p class="text-caption mb-2">Or create a new fandom:</p>
|
||||
<div class="d-flex" style="gap: 8px;">
|
||||
<v-text-field
|
||||
ref="newNameRef"
|
||||
v-model="newName" placeholder="New fandom name"
|
||||
density="compact" hide-details
|
||||
@keydown.enter.prevent="onCreate"
|
||||
@@ -71,7 +80,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
|
||||
const props = defineProps({ tag: { type: Object, required: true } })
|
||||
@@ -84,10 +93,25 @@ const newName = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref(null)
|
||||
const collision = ref(null)
|
||||
const menuOpen = ref(false)
|
||||
const fandomRef = ref(null)
|
||||
const newNameRef = ref(null)
|
||||
|
||||
// Always refresh on open so the list reflects fandoms created elsewhere (#712).
|
||||
onMounted(() => store.loadFandoms())
|
||||
|
||||
// Exposed so the parent dialog can focus the search field on @after-enter —
|
||||
// the reliable point to grab focus (the dialog transition + focus-trap are done).
|
||||
function focusSearch() {
|
||||
nextTick(() => {
|
||||
fandomRef.value?.focus?.()
|
||||
// Keep the menu closed after a programmatic focus so the very next Enter
|
||||
// accepts the value (Saves) instead of being swallowed as a menu interaction.
|
||||
menuOpen.value = false
|
||||
})
|
||||
}
|
||||
defineExpose({ focusSearch })
|
||||
|
||||
async function onCreate() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
@@ -97,6 +121,9 @@ async function onCreate() {
|
||||
const f = await store.createFandom(name)
|
||||
selectedId.value = f.id
|
||||
newName.value = ''
|
||||
// The created fandom is now the selection; hand focus back to the dropdown
|
||||
// so a single Enter saves it (matches FandomPicker's flow).
|
||||
focusSearch()
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e)
|
||||
} finally {
|
||||
@@ -104,6 +131,29 @@ async function onCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
// Enter on the search field — bound in the CAPTURE phase so it runs BEFORE
|
||||
// Vuetify's own handler (which opens the menu on Enter). Menu open → let Vuetify
|
||||
// pick the highlighted item. Menu closed with a changed selection → Save and stop
|
||||
// the event so Vuetify never (re)opens the dropdown (the bug this fixes).
|
||||
function onSearchEnter(e) {
|
||||
if (menuOpen.value) return
|
||||
if (busy.value) return
|
||||
if (selectedId.value !== (props.tag.fandom_id ?? null)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onSave()
|
||||
}
|
||||
}
|
||||
|
||||
// Tab from the search field goes to the "new fandom" text field (operator-
|
||||
// specified). Shift+Tab keeps normal reverse traversal.
|
||||
function onSearchTab(e) {
|
||||
if (e.shiftKey) return
|
||||
e.preventDefault()
|
||||
menuOpen.value = false
|
||||
nextTick(() => newNameRef.value?.focus?.())
|
||||
}
|
||||
|
||||
async function save(merge) {
|
||||
busy.value = true
|
||||
error.value = null
|
||||
|
||||
@@ -34,8 +34,12 @@
|
||||
/>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="fandomDialog" max-width="460">
|
||||
<v-dialog
|
||||
v-model="fandomDialog" max-width="460"
|
||||
@after-enter="fandomSetRef?.focusSearch?.()"
|
||||
>
|
||||
<FandomSetDialog
|
||||
ref="fandomSetRef"
|
||||
v-if="fandomTarget" :tag="fandomTarget"
|
||||
@updated="onFandomUpdated" @cancel="fandomDialog = false"
|
||||
/>
|
||||
@@ -98,6 +102,7 @@ async function onRenamed() {
|
||||
|
||||
const fandomDialog = ref(false)
|
||||
const fandomTarget = ref(null)
|
||||
const fandomSetRef = ref(null)
|
||||
function openSetFandom(tag) {
|
||||
fandomTarget.value = tag
|
||||
fandomDialog.value = true
|
||||
|
||||
@@ -2,25 +2,55 @@
|
||||
<v-card>
|
||||
<v-card-title>Rename tag</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="newName" label="New name" density="compact"
|
||||
autofocus @keydown.enter="submit"
|
||||
/>
|
||||
<v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-2">
|
||||
{{ errorMsg }}
|
||||
<div v-if="isCollision" class="text-caption mt-1">
|
||||
Merging two tags into one lands in FC-2c.
|
||||
</div>
|
||||
</v-alert>
|
||||
<template v-if="!collision">
|
||||
<v-text-field
|
||||
v-model="newName" label="New name" density="compact"
|
||||
autofocus @keydown.enter="submit"
|
||||
/>
|
||||
<v-alert
|
||||
v-if="errorMsg" type="error" variant="tonal" density="compact"
|
||||
class="mt-2"
|
||||
>{{ errorMsg }}</v-alert>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- A tag of this (kind, fandom) already has that name. Renaming onto it
|
||||
is a merge, not a fork — same resolution the Tags view offers. -->
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
A {{ tag.kind }} tag named “{{ collision.target.name }}” already exists.
|
||||
</v-alert>
|
||||
<p class="text-body-2">
|
||||
Merge “{{ tag.name }}” into “{{ collision.target.name }}”?
|
||||
{{ collision.source_image_count }} image
|
||||
association{{ collision.source_image_count === 1 ? '' : 's' }}
|
||||
will move over and this tag will be deleted{{
|
||||
collision.will_alias ? ' (its name kept as a tagger alias)' : '' }}.
|
||||
</p>
|
||||
<v-alert
|
||||
v-if="errorMsg" type="error" variant="tonal" density="compact"
|
||||
class="mt-3"
|
||||
>{{ errorMsg }}</v-alert>
|
||||
</template>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="$emit('cancel')">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary" rounded="pill"
|
||||
:disabled="!newName.trim() || newName === tag.name"
|
||||
:loading="busy" @click="submit"
|
||||
>Rename</v-btn>
|
||||
<template v-if="!collision">
|
||||
<v-btn :disabled="busy" @click="$emit('cancel')">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary" rounded="pill"
|
||||
:disabled="!newName.trim() || newName === tag.name"
|
||||
:loading="busy" @click="submit"
|
||||
>Rename</v-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-btn :disabled="busy" @click="collision = null; errorMsg = null">
|
||||
Back
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="warning" variant="flat" rounded="pill"
|
||||
:loading="busy" @click="onMerge"
|
||||
>Merge</v-btn>
|
||||
</template>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -35,22 +65,41 @@ const emit = defineEmits(['renamed', 'cancel'])
|
||||
const api = useApi()
|
||||
const newName = ref(props.tag.name)
|
||||
const errorMsg = ref(null)
|
||||
const isCollision = ref(false)
|
||||
const collision = ref(null)
|
||||
const busy = ref(false)
|
||||
|
||||
async function submit() {
|
||||
if (!newName.value.trim() || newName.value === props.tag.name) return
|
||||
busy.value = true
|
||||
errorMsg.value = null
|
||||
isCollision.value = false
|
||||
try {
|
||||
const updated = await api.patch(`/api/tags/${props.tag.id}`, {
|
||||
body: { name: newName.value.trim() }
|
||||
})
|
||||
emit('renamed', updated)
|
||||
} catch (e) {
|
||||
// 409 → the new name collides with an existing tag of the same
|
||||
// (kind, fandom). Offer to merge into it rather than dead-ending.
|
||||
if (e.status === 409 && e.body && e.body.target) {
|
||||
collision.value = e.body
|
||||
} else {
|
||||
errorMsg.value = e.message
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onMerge() {
|
||||
busy.value = true
|
||||
errorMsg.value = null
|
||||
try {
|
||||
await api.post(`/api/tags/${props.tag.id}/merge`, {
|
||||
body: { target_id: collision.value.target.id }
|
||||
})
|
||||
emit('renamed', { id: collision.value.target.id, name: collision.value.target.name })
|
||||
} catch (e) {
|
||||
errorMsg.value = e.message
|
||||
isCollision.value = e.status === 409
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
|
||||
const tagId = ref(null)
|
||||
const series = ref(null)
|
||||
const chapters = ref([]) // [{id, chapter_number, title, is_placeholder, stated_page_start/end, pages:[...]}]
|
||||
const gaps = ref([]) // [{after_chapter_id, start, end}]
|
||||
const chapters = ref([]) // [{id, chapter_number, stated_part, title, is_placeholder, stated_page_start/end, source_post, pages:[...]}]
|
||||
const gaps = ref([]) // missing-page gaps: [{after_chapter_id, start, end}]
|
||||
const partGaps = ref([]) // missing-Part gaps: [{after_chapter_id, start, end}]
|
||||
const pageCount = ref(0)
|
||||
const targetChapterId = ref(null) // which chapter the picker adds into
|
||||
const picker = ref([]) // gallery scroll results
|
||||
@@ -33,6 +34,7 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
series.value = body.series
|
||||
chapters.value = body.chapters || []
|
||||
gaps.value = body.gaps || []
|
||||
partGaps.value = body.part_gaps || []
|
||||
pageCount.value = (body.pages || []).length
|
||||
// Keep a valid add-target: the selected chapter, else the first one.
|
||||
const ids = chapters.value.map(c => c.id)
|
||||
@@ -52,6 +54,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
return gaps.value.find(g => g.after_chapter_id === chapterId) || null
|
||||
}
|
||||
|
||||
function partGapAfter(chapterId) {
|
||||
return partGaps.value.find(g => g.after_chapter_id === chapterId) || null
|
||||
}
|
||||
|
||||
// ---- chapters ----
|
||||
async function createChapter({ title = null, isPlaceholder = false } = {}) {
|
||||
await api.post(`/api/series/${tagId.value}/chapters`, {
|
||||
@@ -67,6 +73,13 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function setChapterPart(chapterId, part) {
|
||||
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
|
||||
body: { stated_part: part }
|
||||
})
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function setChapterStated(chapterId, start, end) {
|
||||
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
|
||||
body: { stated_page_start: start, stated_page_end: end }
|
||||
@@ -151,11 +164,11 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
tagId, series, chapters, gaps, pageCount, targetChapterId,
|
||||
tagId, series, chapters, gaps, partGaps, pageCount, targetChapterId,
|
||||
picker, pickerCursor, pickerSelection, loading,
|
||||
load, refresh, gapAfter,
|
||||
createChapter, renameChapter, setChapterStated, reorderChapters,
|
||||
moveChapter, deleteChapter, mergeChapter, reorderPages,
|
||||
load, refresh, gapAfter, partGapAfter,
|
||||
createChapter, renameChapter, setChapterPart, setChapterStated,
|
||||
reorderChapters, moveChapter, deleteChapter, mergeChapter, reorderPages,
|
||||
loadPicker, togglePick, addSelected, remove, setCover
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,159 +1,227 @@
|
||||
<template>
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<v-container fluid class="pt-2 pb-10 fc-series">
|
||||
<div class="fc-series__head">
|
||||
<span class="fc-series__name">{{ store.series?.name || 'Series' }}</span>
|
||||
<span class="fc-series__count">
|
||||
{{ store.chapters.length }} chapter(s) · {{ store.pageCount }} page(s)
|
||||
{{ store.chapters.length }} part(s) · {{ store.pageCount }} page(s)
|
||||
</span>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="store.pageCount > 0"
|
||||
size="small" variant="tonal" color="accent"
|
||||
prepend-icon="mdi-book-open"
|
||||
class="fc-series__read"
|
||||
@click="$router.push({ name: 'series-read', params: { tagId: store.tagId } })"
|
||||
>Read</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="fc-series__body">
|
||||
<!-- Chapters column -->
|
||||
<div class="fc-series__chapters">
|
||||
<template v-for="(ch, ci) in store.chapters" :key="ch.id">
|
||||
<section
|
||||
class="fc-chapter"
|
||||
:class="{ 'fc-chapter--target': ch.id === store.targetChapterId }"
|
||||
>
|
||||
<header class="fc-chapter__head">
|
||||
<span class="fc-chapter__num">{{ ch.chapter_number }}</span>
|
||||
<v-text-field
|
||||
v-model="titleDraft[ch.id]"
|
||||
:placeholder="`Chapter ${ch.chapter_number}`"
|
||||
density="compact" variant="plain" hide-details
|
||||
class="fc-chapter__title"
|
||||
@keydown.enter="commitTitle(ch)"
|
||||
@blur="commitTitle(ch)"
|
||||
/>
|
||||
<span v-if="ch.is_placeholder" class="fc-chapter__ph">placeholder</span>
|
||||
<span v-else class="fc-chapter__pc">{{ ch.pages.length }} pg</span>
|
||||
<p class="fc-series__hint">
|
||||
Each part is one installment — drag pages to set their order, and set the
|
||||
<strong>Part #</strong> to mark where it falls in the story. Use
|
||||
<strong>Add pages</strong> to pull images from the gallery into the part
|
||||
you're working on.
|
||||
</p>
|
||||
|
||||
<div class="fc-chapter__stated">
|
||||
<input
|
||||
class="fc-chapter__num-in" type="number" min="0"
|
||||
:value="ch.stated_page_start ?? ''" placeholder="–"
|
||||
title="Stated first page"
|
||||
@change="onStated(ch, 'start', $event)"
|
||||
>
|
||||
<span>–</span>
|
||||
<input
|
||||
class="fc-chapter__num-in" type="number" min="0"
|
||||
:value="ch.stated_page_end ?? ''" placeholder="–"
|
||||
title="Stated last page"
|
||||
@change="onStated(ch, 'end', $event)"
|
||||
>
|
||||
</div>
|
||||
<!-- Parts, full width -->
|
||||
<div class="fc-parts">
|
||||
<template v-for="(ch, ci) in store.chapters" :key="ch.id">
|
||||
<section class="fc-part">
|
||||
<header class="fc-part__head">
|
||||
<label class="fc-part__partfield" :title="'Part number for this installment'">
|
||||
<span class="fc-part__partlabel">Part</span>
|
||||
<input
|
||||
class="fc-part__partnum" type="number" min="1"
|
||||
:value="partDraft[ch.id] ?? ''"
|
||||
:placeholder="String(ch.chapter_number)"
|
||||
@input="partDraft[ch.id] = $event.target.value"
|
||||
@keydown.enter.prevent="commitPart(ch)"
|
||||
@blur="commitPart(ch)"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="fc-chapter__actions">
|
||||
<v-text-field
|
||||
v-model="titleDraft[ch.id]"
|
||||
:placeholder="`Untitled — Part ${ch.stated_part ?? ch.chapter_number}`"
|
||||
density="compact" variant="plain" hide-details
|
||||
class="fc-part__title"
|
||||
@keydown.enter="commitTitle(ch)"
|
||||
@blur="commitTitle(ch)"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="ch.source_post?.title" class="fc-part__src"
|
||||
:title="ch.source_post.title"
|
||||
>
|
||||
<v-icon size="x-small">mdi-link-variant</v-icon>
|
||||
{{ ch.source_post.title }}
|
||||
</span>
|
||||
|
||||
<span v-if="ch.is_placeholder" class="fc-part__badge">placeholder</span>
|
||||
<span v-else class="fc-part__pc">{{ ch.pages.length }} pg</span>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<v-btn
|
||||
v-if="!ch.is_placeholder"
|
||||
size="small" variant="tonal" color="accent"
|
||||
prepend-icon="mdi-image-plus"
|
||||
@click="openPicker(ch.id)"
|
||||
>Add pages</v-btn>
|
||||
|
||||
<v-menu location="bottom end">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
size="x-small" variant="text" icon="mdi-chevron-up"
|
||||
title="Move chapter up" :disabled="ci === 0"
|
||||
v-bind="props" size="small" variant="text"
|
||||
icon="mdi-dots-vertical" title="Part actions"
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
prepend-icon="mdi-chevron-up" title="Move up"
|
||||
:disabled="ci === 0"
|
||||
@click="store.moveChapter(ch.id, -1)"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small" variant="text" icon="mdi-chevron-down"
|
||||
title="Move chapter down"
|
||||
<v-list-item
|
||||
prepend-icon="mdi-chevron-down" title="Move down"
|
||||
:disabled="ci === store.chapters.length - 1"
|
||||
@click="store.moveChapter(ch.id, 1)"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small" variant="text" icon="mdi-arrow-collapse-up"
|
||||
title="Merge into previous chapter" :disabled="ci === 0"
|
||||
<v-list-item
|
||||
prepend-icon="mdi-arrow-collapse-up" title="Merge into previous"
|
||||
:disabled="ci === 0"
|
||||
@click="store.mergeChapter(ch.id, store.chapters[ci - 1].id)"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small" variant="text" icon="mdi-target"
|
||||
:color="ch.id === store.targetChapterId ? 'accent' : undefined"
|
||||
title="Add picked images here"
|
||||
@click="store.targetChapterId = ch.id"
|
||||
<v-divider />
|
||||
<v-list-item
|
||||
prepend-icon="mdi-delete-outline" title="Delete part"
|
||||
base-color="error"
|
||||
@click="confirmDelete(ch)"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small" variant="text" icon="mdi-delete-outline"
|
||||
title="Delete chapter (removes its pages from the series)"
|
||||
@click="store.deleteChapter(ch.id)"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</header>
|
||||
|
||||
<div v-if="ch.is_placeholder" class="fc-chapter__reserved">
|
||||
Reserved slot — a section you don't have yet.
|
||||
</div>
|
||||
<div v-else-if="ch.pages.length === 0" class="fc-chapter__empty">
|
||||
No pages — pick this chapter (◎) then add from the right.
|
||||
</div>
|
||||
<div v-else class="fc-chapter__pages">
|
||||
<div
|
||||
v-for="(p, pi) in ch.pages" :key="p.image_id"
|
||||
class="fc-page" draggable="true"
|
||||
@dragstart="drag = { chapterId: ch.id, idx: pi }"
|
||||
@dragover.prevent
|
||||
@drop="onPageDrop(ch, pi)"
|
||||
>
|
||||
<span class="fc-page__pn">{{ p.page_number }}</span>
|
||||
<img :src="p.thumbnail_url" alt="" loading="lazy" />
|
||||
<div class="fc-page__actions">
|
||||
<v-btn size="x-small" variant="text" icon="mdi-image-frame"
|
||||
title="Make cover" @click="store.setCover(p.image_id)" />
|
||||
<v-btn size="x-small" variant="text" icon="mdi-close"
|
||||
title="Remove" @click="store.remove(p.image_id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="store.gapAfter(ch.id)"
|
||||
class="fc-gap"
|
||||
>
|
||||
<v-icon size="x-small">mdi-alert-outline</v-icon>
|
||||
Gap: pages {{ store.gapAfter(ch.id).start }}–{{ store.gapAfter(ch.id).end }} missing
|
||||
<div class="fc-part__statedrow">
|
||||
<span class="fc-part__statedlabel">Printed pages</span>
|
||||
<input
|
||||
class="fc-part__statedin" type="number" min="0"
|
||||
:value="ch.stated_page_start ?? ''" placeholder="start"
|
||||
@change="onStated(ch, 'start', $event)"
|
||||
>
|
||||
<span class="fc-part__dash">–</span>
|
||||
<input
|
||||
class="fc-part__statedin" type="number" min="0"
|
||||
:value="ch.stated_page_end ?? ''" placeholder="end"
|
||||
@change="onStated(ch, 'end', $event)"
|
||||
>
|
||||
<span class="fc-part__statedhelp">
|
||||
optional — the page numbers printed in this installment
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="store.chapters.length === 0" class="fc-series__empty">
|
||||
No chapters yet — add one, then add images from the right.
|
||||
</div>
|
||||
<div v-if="ch.is_placeholder" class="fc-part__reserved">
|
||||
Reserved slot — a part you don't have yet.
|
||||
</div>
|
||||
<div v-else-if="ch.pages.length === 0" class="fc-part__empty">
|
||||
No pages yet.
|
||||
<v-btn
|
||||
size="small" variant="text" color="accent"
|
||||
prepend-icon="mdi-image-plus" @click="openPicker(ch.id)"
|
||||
>Add pages</v-btn>
|
||||
</div>
|
||||
<div v-else class="fc-part__pages">
|
||||
<div
|
||||
v-for="(p, pi) in ch.pages" :key="p.image_id"
|
||||
class="fc-page" draggable="true"
|
||||
:class="{ 'fc-page--dragging': drag && drag.chapterId === ch.id && drag.idx === pi }"
|
||||
@dragstart="drag = { chapterId: ch.id, idx: pi }"
|
||||
@dragend="drag = null"
|
||||
@dragover.prevent
|
||||
@drop="onPageDrop(ch, pi)"
|
||||
>
|
||||
<span class="fc-page__pn">{{ p.page_number }}</span>
|
||||
<img :src="p.thumbnail_url" alt="" loading="lazy" />
|
||||
<div class="fc-page__actions">
|
||||
<v-btn size="x-small" variant="flat" icon="mdi-image-frame"
|
||||
title="Make series cover" @click="store.setCover(p.image_id)" />
|
||||
<v-btn size="x-small" variant="flat" icon="mdi-close"
|
||||
title="Remove from series" @click="store.remove(p.image_id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="fc-series__chapter-add">
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus"
|
||||
@click="store.createChapter()">Add chapter</v-btn>
|
||||
<v-btn size="small" variant="text" prepend-icon="mdi-bookmark-outline"
|
||||
@click="store.createChapter({ isPlaceholder: true })">
|
||||
Add placeholder
|
||||
</v-btn>
|
||||
<div v-if="store.partGapAfter(ch.id)" class="fc-gap">
|
||||
<v-icon size="x-small">mdi-alert-outline</v-icon>
|
||||
Missing
|
||||
<template v-if="store.partGapAfter(ch.id).start === store.partGapAfter(ch.id).end">
|
||||
Part {{ store.partGapAfter(ch.id).start }}
|
||||
</template>
|
||||
<template v-else>
|
||||
Parts {{ store.partGapAfter(ch.id).start }}–{{ store.partGapAfter(ch.id).end }}
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="store.gapAfter(ch.id)" class="fc-gap">
|
||||
<v-icon size="x-small">mdi-alert-outline</v-icon>
|
||||
Gap: printed pages {{ store.gapAfter(ch.id).start }}–{{ store.gapAfter(ch.id).end }} missing
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="store.chapters.length === 0" class="fc-series__empty">
|
||||
No parts yet — add one, then add pages from the gallery.
|
||||
</div>
|
||||
|
||||
<!-- Picker column -->
|
||||
<div class="fc-series__picker">
|
||||
<div class="fc-series__pickerhead">
|
||||
<div class="fc-parts__add">
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus"
|
||||
@click="store.createChapter()">Add part</v-btn>
|
||||
<v-btn size="small" variant="text" prepend-icon="mdi-bookmark-outline"
|
||||
@click="store.createChapter({ isPlaceholder: true })">
|
||||
Add placeholder
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Picker slide-over -->
|
||||
<v-navigation-drawer
|
||||
v-model="pickerOpen" location="right" temporary width="420"
|
||||
class="fc-picker"
|
||||
>
|
||||
<div class="fc-picker__head">
|
||||
<div class="fc-picker__title">
|
||||
<span>Add pages</span>
|
||||
<v-btn size="x-small" variant="text" icon="mdi-close"
|
||||
@click="pickerOpen = false" />
|
||||
</div>
|
||||
<v-select
|
||||
v-model="store.targetChapterId"
|
||||
:items="chapterItems" item-title="label" item-value="id"
|
||||
density="compact" variant="outlined" hide-details
|
||||
label="Add to part" class="mt-2"
|
||||
/>
|
||||
<div class="fc-picker__bar">
|
||||
<span>{{ store.pickerSelection.length }} selected</span>
|
||||
<v-btn
|
||||
size="small" color="accent" variant="flat"
|
||||
:disabled="store.pickerSelection.length === 0"
|
||||
@click="store.addSelected()"
|
||||
>Add to {{ targetLabel }}</v-btn>
|
||||
>Add to part</v-btn>
|
||||
</div>
|
||||
<div class="fc-series__pickergrid">
|
||||
<div
|
||||
v-for="img in store.picker" :key="img.id"
|
||||
class="fc-series__pick"
|
||||
:class="{ on: store.pickerSelection.includes(img.id) }"
|
||||
@click="store.togglePick(img.id)"
|
||||
>
|
||||
<img :src="img.thumbnail_url" alt="" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
<div ref="sentinel" class="fc-series__sentinel" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="fc-picker__grid">
|
||||
<div
|
||||
v-for="img in store.picker" :key="img.id"
|
||||
class="fc-picker__pick"
|
||||
:class="{ on: store.pickerSelection.includes(img.id) }"
|
||||
@click="store.togglePick(img.id)"
|
||||
>
|
||||
<img :src="img.thumbnail_url" alt="" loading="lazy" />
|
||||
<v-icon
|
||||
v-if="store.pickerSelection.includes(img.id)"
|
||||
class="fc-picker__check" color="accent" size="small"
|
||||
>mdi-check-circle</v-icon>
|
||||
</div>
|
||||
<div ref="sentinel" class="fc-picker__sentinel" />
|
||||
</div>
|
||||
</v-navigation-drawer>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@@ -167,20 +235,29 @@ const route = useRoute()
|
||||
const store = useSeriesManageStore()
|
||||
const sentinel = ref(null)
|
||||
const drag = ref(null) // { chapterId, idx }
|
||||
const titleDraft = reactive({}) // chapterId -> draft string
|
||||
const titleDraft = reactive({}) // chapterId -> draft title
|
||||
const partDraft = reactive({}) // chapterId -> draft stated_part (string)
|
||||
const pickerOpen = ref(false)
|
||||
|
||||
// Keep local title drafts in sync with the loaded chapters. Edits commit on
|
||||
// blur/Enter, which refreshes and resets the draft to the saved value.
|
||||
// Keep local drafts in sync with loaded chapters. Edits commit on blur/Enter,
|
||||
// which refreshes and resets the draft to the saved value.
|
||||
watch(() => store.chapters, (chs) => {
|
||||
Object.keys(titleDraft).forEach(k => delete titleDraft[k])
|
||||
for (const c of chs) titleDraft[c.id] = c.title || ''
|
||||
for (const k of Object.keys(titleDraft)) delete titleDraft[k]
|
||||
for (const k of Object.keys(partDraft)) delete partDraft[k]
|
||||
for (const c of chs) {
|
||||
titleDraft[c.id] = c.title || ''
|
||||
partDraft[c.id] = c.stated_part == null ? '' : String(c.stated_part)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const targetLabel = computed(() => {
|
||||
const ch = store.chapters.find(c => c.id === store.targetChapterId)
|
||||
if (!ch) return 'chapter'
|
||||
return ch.title || `Chapter ${ch.chapter_number}`
|
||||
})
|
||||
const chapterItems = computed(() =>
|
||||
store.chapters.map(c => ({
|
||||
id: c.id,
|
||||
label: `Part ${c.stated_part ?? c.chapter_number}` +
|
||||
(c.title ? ` — ${c.title}` : '') +
|
||||
(c.is_placeholder ? ' (placeholder)' : ` · ${c.pages.length} pg`),
|
||||
}))
|
||||
)
|
||||
|
||||
function commitTitle(ch) {
|
||||
const v = (titleDraft[ch.id] || '').trim()
|
||||
@@ -188,6 +265,17 @@ function commitTitle(ch) {
|
||||
store.renameChapter(ch.id, v || null)
|
||||
}
|
||||
|
||||
function commitPart(ch) {
|
||||
const raw = (partDraft[ch.id] ?? '').trim()
|
||||
const next = raw === '' ? null : parseInt(raw, 10)
|
||||
if (raw !== '' && (Number.isNaN(next) || next < 1)) {
|
||||
partDraft[ch.id] = ch.stated_part == null ? '' : String(ch.stated_part)
|
||||
return
|
||||
}
|
||||
if (next === (ch.stated_part ?? null)) return
|
||||
store.setChapterPart(ch.id, next)
|
||||
}
|
||||
|
||||
function onStated(ch, which, ev) {
|
||||
const raw = ev.target.value
|
||||
const n = raw === '' ? null : parseInt(raw, 10)
|
||||
@@ -206,6 +294,20 @@ function onPageDrop(chapter, toIdx) {
|
||||
store.reorderPages(chapter.id, ordered)
|
||||
}
|
||||
|
||||
function confirmDelete(ch) {
|
||||
const label = `Part ${ch.stated_part ?? ch.chapter_number}`
|
||||
const n = ch.pages.length
|
||||
const msg = n
|
||||
? `Delete ${label} and remove its ${n} page(s) from the series?`
|
||||
: `Delete ${label}?`
|
||||
if (window.confirm(msg)) store.deleteChapter(ch.id)
|
||||
}
|
||||
|
||||
function openPicker(chapterId) {
|
||||
store.targetChapterId = chapterId
|
||||
pickerOpen.value = true
|
||||
}
|
||||
|
||||
useInfiniteScroll(sentinel, () => store.loadPicker())
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -216,91 +318,146 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.fc-series__head {
|
||||
display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px;
|
||||
display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;
|
||||
}
|
||||
.fc-series__name { font-family: 'Fraunces', Georgia, serif; font-size: 22px; }
|
||||
.fc-series__name { font-family: 'Fraunces', Georgia, serif; font-size: 24px; }
|
||||
.fc-series__count {
|
||||
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-series__body { display: flex; gap: 16px; align-items: flex-start; }
|
||||
.fc-series__chapters {
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px;
|
||||
.fc-series__hint {
|
||||
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
margin-bottom: 18px; max-width: 760px;
|
||||
}
|
||||
|
||||
.fc-chapter {
|
||||
/* Parts — full width, stacked */
|
||||
.fc-parts { display: flex; flex-direction: column; gap: 14px; max-width: 1100px; }
|
||||
.fc-part {
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
border-radius: 8px; padding: 8px 10px;
|
||||
border-radius: 10px; padding: 12px 14px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.fc-chapter--target { border-color: rgb(var(--v-theme-accent), 0.7); }
|
||||
.fc-chapter__head { display: flex; align-items: center; gap: 8px; }
|
||||
.fc-chapter__num {
|
||||
flex: 0 0 auto; min-width: 22px; height: 22px; border-radius: 4px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; font-variant-numeric: tabular-nums;
|
||||
background: rgb(var(--v-theme-accent), 0.16);
|
||||
.fc-part__head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
|
||||
.fc-part__partfield {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: rgb(var(--v-theme-accent), 0.12);
|
||||
border: 1px solid rgb(var(--v-theme-accent), 0.35);
|
||||
border-radius: 8px; padding: 4px 8px;
|
||||
}
|
||||
.fc-part__partlabel {
|
||||
font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-chapter__title { flex: 1; min-width: 0; }
|
||||
.fc-chapter__ph {
|
||||
.fc-part__partnum {
|
||||
width: 46px; text-align: center; font-size: 18px; font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: transparent; border: none; color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-part__partnum:focus { outline: none; }
|
||||
.fc-part__partnum::placeholder { color: rgb(var(--v-theme-on-surface-variant)); opacity: 0.6; }
|
||||
|
||||
.fc-part__title { flex: 1 1 180px; min-width: 120px; font-size: 15px; }
|
||||
.fc-part__src {
|
||||
display: inline-flex; align-items: center; gap: 4px; max-width: 240px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-part__badge {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-chapter__pc {
|
||||
.fc-part__pc {
|
||||
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.fc-chapter__stated { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.fc-chapter__num-in {
|
||||
width: 42px; text-align: center; font-size: 12px;
|
||||
|
||||
.fc-part__statedrow {
|
||||
display: flex; align-items: center; gap: 6px; margin: 8px 0 2px;
|
||||
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-part__statedlabel { text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; }
|
||||
.fc-part__statedin {
|
||||
width: 56px; text-align: center; font-size: 12px;
|
||||
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-chapter__num-in:focus { outline: none; border-color: rgb(var(--v-theme-accent)); }
|
||||
.fc-chapter__actions { flex: 0 0 auto; display: inline-flex; }
|
||||
.fc-chapter__reserved, .fc-chapter__empty {
|
||||
padding: 14px; text-align: center; font-size: 13px;
|
||||
.fc-part__statedin:focus { outline: none; border-color: rgb(var(--v-theme-accent)); }
|
||||
.fc-part__dash { opacity: 0.6; }
|
||||
.fc-part__statedhelp { font-size: 11px; opacity: 0.7; }
|
||||
|
||||
.fc-part__reserved, .fc-part__empty {
|
||||
padding: 18px; text-align: center; font-size: 13px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-chapter__pages { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
|
||||
|
||||
/* Big page grid */
|
||||
.fc-part__pages {
|
||||
display: grid; gap: 10px; margin-top: 10px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
.fc-page {
|
||||
display: flex; align-items: center; gap: 10px; padding: 6px;
|
||||
background: rgb(var(--v-theme-surface-light)); border-radius: 6px; cursor: grab;
|
||||
position: relative; border-radius: 8px; overflow: hidden; cursor: grab;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
.fc-page--dragging { opacity: 0.4; }
|
||||
.fc-page img {
|
||||
width: 100%; aspect-ratio: 3 / 4; object-fit: contain;
|
||||
display: block; background: rgb(var(--v-theme-background));
|
||||
}
|
||||
.fc-page img { width: 56px; height: 56px; object-fit: cover; border-radius: 4px; }
|
||||
.fc-page__pn {
|
||||
width: 26px; text-align: center; font-variant-numeric: tabular-nums;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
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));
|
||||
}
|
||||
.fc-page__actions { margin-left: auto; }
|
||||
.fc-page__actions {
|
||||
position: absolute; top: 4px; right: 4px; z-index: 2;
|
||||
display: flex; gap: 2px; opacity: 0; transition: opacity 0.12s;
|
||||
}
|
||||
.fc-page:hover .fc-page__actions, .fc-page:focus-within .fc-page__actions { opacity: 1; }
|
||||
.fc-page__actions :deep(.v-btn) {
|
||||
background: rgba(0, 0, 0, 0.55); color: #fff;
|
||||
}
|
||||
|
||||
.fc-gap {
|
||||
display: flex; align-items: center; gap: 6px; padding: 4px 10px;
|
||||
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__chapter-add { display: flex; gap: 8px; margin-top: 4px; }
|
||||
.fc-parts__add { display: flex; gap: 8px; margin-top: 4px; }
|
||||
.fc-series__empty {
|
||||
padding: 32px; text-align: center; color: rgb(var(--v-theme-on-surface-variant));
|
||||
padding: 40px; text-align: center; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
|
||||
.fc-series__picker { flex: 1; min-width: 0; }
|
||||
.fc-series__pickerhead {
|
||||
/* Picker slide-over */
|
||||
.fc-picker__head {
|
||||
position: sticky; top: 0; z-index: 1; padding: 12px 14px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-bottom: 1px solid rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
.fc-picker__title {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-family: 'Fraunces', Georgia, serif; font-size: 18px;
|
||||
}
|
||||
.fc-series__pickergrid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 6px;
|
||||
.fc-picker__bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-top: 10px; font-size: 13px;
|
||||
}
|
||||
.fc-series__pick {
|
||||
cursor: pointer; aspect-ratio: 1; overflow: hidden;
|
||||
border-radius: 4px; outline: 2px solid transparent;
|
||||
.fc-picker__grid {
|
||||
display: grid; gap: 6px; padding: 12px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
}
|
||||
.fc-series__pick img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.fc-series__pick.on { outline-color: rgb(var(--v-theme-accent)); }
|
||||
.fc-series__sentinel { height: 40px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fc-series__body { flex-direction: column; }
|
||||
.fc-picker__pick {
|
||||
position: relative; cursor: pointer; aspect-ratio: 1; overflow: hidden;
|
||||
border-radius: 6px; outline: 2px solid transparent;
|
||||
}
|
||||
.fc-picker__pick img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.fc-picker__pick.on { outline-color: rgb(var(--v-theme-accent)); }
|
||||
.fc-picker__check {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
background: rgba(0, 0, 0, 0.5); border-radius: 50%;
|
||||
}
|
||||
.fc-picker__sentinel { grid-column: 1 / -1; height: 40px; }
|
||||
</style>
|
||||
|
||||
@@ -87,8 +87,12 @@
|
||||
@confirm="onDeleteTagConfirm"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="fandomDialogOpen" max-width="460">
|
||||
<v-dialog
|
||||
v-model="fandomDialogOpen" max-width="460"
|
||||
@after-enter="fandomSetRef?.focusSearch?.()"
|
||||
>
|
||||
<FandomSetDialog
|
||||
ref="fandomSetRef"
|
||||
v-if="fandomTarget" :tag="fandomTarget"
|
||||
@updated="onFandomUpdated" @cancel="fandomDialogOpen = false"
|
||||
/>
|
||||
@@ -152,6 +156,7 @@ function openTag(tagId) {
|
||||
// Character fandom editing (dots-menu → FandomSetDialog).
|
||||
const fandomDialogOpen = ref(false)
|
||||
const fandomTarget = ref(null)
|
||||
const fandomSetRef = ref(null)
|
||||
function onSetFandom(card) {
|
||||
fandomTarget.value = card
|
||||
fandomDialogOpen.value = true
|
||||
|
||||
@@ -75,6 +75,33 @@ describe('seriesManage', () => {
|
||||
expect(r.body).toEqual({ chapter_ids: [2, 1, 3] })
|
||||
})
|
||||
|
||||
it('setChapterPart patches stated_part on the chapter', async () => {
|
||||
const s = useSeriesManageStore()
|
||||
s.tagId = 7
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : null })
|
||||
if (init.method === 'PATCH') return { status: 200, body: { ok: true } }
|
||||
return { status: 200, body: SERIES_BODY }
|
||||
})
|
||||
await s.setChapterPart(1, 2)
|
||||
const p = calls.find(c => c.method === 'PATCH')
|
||||
expect(p.url).toContain('/api/series/7/chapters/1')
|
||||
expect(p.body).toEqual({ stated_part: 2 })
|
||||
})
|
||||
|
||||
it('load surfaces part_gaps and partGapAfter looks them up', async () => {
|
||||
const s = useSeriesManageStore()
|
||||
stubFetch(() => ({
|
||||
status: 200,
|
||||
body: { ...SERIES_BODY, part_gaps: [{ after_chapter_id: 1, start: 2, end: 2 }] }
|
||||
}))
|
||||
await s.load(7)
|
||||
expect(s.partGaps).toHaveLength(1)
|
||||
expect(s.partGapAfter(1)).toEqual({ after_chapter_id: 1, start: 2, end: 2 })
|
||||
expect(s.partGapAfter(99)).toBeNull()
|
||||
})
|
||||
|
||||
it('addSelected posts selection + target chapter then clears', async () => {
|
||||
const s = useSeriesManageStore()
|
||||
s.tagId = 7
|
||||
|
||||
@@ -113,3 +113,52 @@ async def test_list_series_cards(db):
|
||||
# artist filter keeps it; a different artist id drops it.
|
||||
assert any(r["id"] == out["series_tag_id"] for r in await svc.list_series(artist_id=artist.id))
|
||||
assert all(r["id"] != out["series_tag_id"] for r in await svc.list_series(artist_id=artist.id + 99999))
|
||||
|
||||
|
||||
# --- FC-6.4: stated_part, part_gaps, source_post label ---------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_chapter_sets_and_clears_stated_part(db):
|
||||
svc = SeriesService(db)
|
||||
sid = (await TagService(db).find_or_create("Part Series", TagKind.series)).id
|
||||
ch = await svc.create_chapter(sid)
|
||||
# Set the installment to Part 2 (chapter_number stays its positional value).
|
||||
await svc.update_chapter(sid, ch["id"], stated_part=2, set_part=True)
|
||||
data = await svc.list_pages(sid)
|
||||
assert data["chapters"][0]["stated_part"] == 2
|
||||
assert data["chapters"][0]["chapter_number"] == 1
|
||||
# Clearing it writes NULL back.
|
||||
await svc.update_chapter(sid, ch["id"], stated_part=None, set_part=True)
|
||||
data = await svc.list_pages(sid)
|
||||
assert data["chapters"][0]["stated_part"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_part_gaps_flagged_from_stated_part(db):
|
||||
svc = SeriesService(db)
|
||||
sid = (await TagService(db).find_or_create("Gappy Series", TagKind.series)).id
|
||||
c1 = await svc.create_chapter(sid)
|
||||
c3 = await svc.create_chapter(sid)
|
||||
await svc.update_chapter(sid, c1["id"], stated_part=1, set_part=True)
|
||||
await svc.update_chapter(sid, c3["id"], stated_part=3, set_part=True)
|
||||
data = await svc.list_pages(sid)
|
||||
assert len(data["part_gaps"]) == 1
|
||||
gap = data["part_gaps"][0]
|
||||
assert gap["after_chapter_id"] == c1["id"]
|
||||
assert gap["start"] == 2
|
||||
assert gap["end"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_post_label_when_pages_share_one_post(db):
|
||||
svc = SeriesService(db)
|
||||
artist = await _artist(db, "Src Artist")
|
||||
post = await _post(db, artist, "Source Comic pages 1-2", "pp5")
|
||||
await _post_images(db, post, artist, 2)
|
||||
out = await svc.promote_post_to_series(post.id)
|
||||
data = await svc.list_pages(out["series_tag_id"])
|
||||
sp = data["chapters"][0]["source_post"]
|
||||
assert sp is not None
|
||||
assert sp["id"] == post.id
|
||||
assert sp["title"] == "Source Comic pages 1-2"
|
||||
|
||||
Reference in New Issue
Block a user