Compare commits

...

8 Commits

Author SHA1 Message Date
bvandeusen 0666e15211 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
2026-06-07 21:17:57 -04:00
bvandeusen a00a2786e3 fix(tags): normalize task fails fast on lock + logs progress
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m5s
normalize_tags_task ran to the 40-min hard limit with zero logs (operator-
flagged 2026-06-07). Cause: a per-group merge repoints series_page (via
_repoint_series_pages); during the wedged 0040 migration that held ACCESS
EXCLUSIVE on series_page, the merge's UPDATE blocked on that lock. The time-box
check is at the top of the group loop, so a statement blocked mid-group never
yields back to it — the task sat until the Celery hard kill. No logs because the
only log fired per *finished* group.

- Set lock_timeout=30s on the normalize session (opt-in server_settings on the
  async factory). A blocked merge now raises, the per-group handler rolls back +
  counts an error, and the loop continues — one stuck group can't strand the
  chunk, and the budget checkpoint stays effective.
- Log group count at start + a heartbeat every 25 groups, so a long/slow run is
  diagnosable instead of silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:02:39 -04:00
bvandeusen 9770dd3474 fix(tags): rename-onto-existing in the image modal now merges, not errors
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 26s
CI / frontend-build (push) Successful in 32s
CI / integration (push) Successful in 3m7s
The image-modal tag kebab's rename dialog still showed a leftover stub
('Merging two tags into one lands in FC-2c') on a name collision, dead-ending
the operator. The merge machinery has existed for a while — the Tags view
already resolves rename collisions this way. Wire TagRenameDialog to it: on the
409 collision hint, show the same merge confirmation FandomSetDialog uses
(target name, image associations moved, alias kept) and POST /api/tags/<id>/merge
into the existing tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:56:45 -04:00
bvandeusen 7daf90f41e fix(allowlist): lower default auto-apply threshold 0.95 → 0.90
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m7s
Operator evidence 2026-06-07: 0.95 was too strict, skipping confident-enough
auto-applications of accepted tags. Newly-accepted tags now allowlist at 0.90;
existing entries keep their stored value and per-tag thresholds stay tunable in
the allowlist table. No migration — min_confidence has no DB server_default, so
the Python insert default governs new rows only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:48:43 -04:00
bvandeusen aaa375654b fix(fandom): match change-fandom modal focus + Enter to FandomPicker
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m6s
The 'Fandom for <character>' dialog (FandomSetDialog) used plain autofocus and
had no Enter handling, so Enter re-opened the dropdown instead of submitting —
the same bug FandomPicker already fixed. Mirror that flow: parent v-dialogs
focus the field via @after-enter→focusSearch (reliable past the focus-trap);
capture-phase Enter Saves the changed selection instead of re-opening the menu;
Tab jumps to the new-fandom field; creating a fandom returns focus to the
dropdown so a single Enter saves it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:37:02 -04:00
bvandeusen 5bc8ef65ad chore: gitignore the .superpowers working dir
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 27s
CI / frontend-build (push) Successful in 40s
CI / integration (push) Successful in 3m5s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:29:10 -04:00
bvandeusen 978959bdc4 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>
2026-06-07 20:29:10 -04:00
bvandeusen 7309d1d6d4 fix(alembic): serialize concurrent migrators with an advisory lock
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m10s
Every web replica runs 'alembic upgrade head' in its entrypoint, so under
docker stack deploy two replicas can boot at once and race the same DDL —
0040 raced in prod (operator-flagged 2026-06-07): one backend wedged on the
series_page lock while a second tried to re-CREATE series_chapter, and the
loser died with AdminShutdown, crash-looping the web service.

Wrap run_migrations() in a transaction-scoped pg_advisory_xact_lock acquired
BEFORE the version table is read. The first replica to reach it migrates and
holds the lock for the whole upgrade; siblings block, then find the version
already at head and apply nothing. Works regardless of replica count and
needs no Swarm depends_on ordering (which stack deploy ignores anyway).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:59:45 -04:00
18 changed files with 725 additions and 213 deletions
+1
View File
@@ -69,3 +69,4 @@ Thumbs.db
alembic/versions/__pycache__/
*.sqlite
*.sqlite-journal
.superpowers/
+22 -1
View File
@@ -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")
+5
View File
@@ -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:
+7
View File
@@ -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"
+5 -1
View File
@@ -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()
)
+50 -1
View File
@@ -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:
+14
View File
@@ -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
+18 -3
View File
@@ -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
+10 -1
View File
@@ -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
+6 -1
View File
@@ -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>
<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 }}
<div v-if="isCollision" class="text-caption mt-1">
Merging two tags into one lands in FC-2c.
</div>
<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>
<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
}
+19 -6
View File
@@ -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
}
})
+291 -134
View File
@@ -1,129 +1,178 @@
<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">
<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>
<!-- Parts, full width -->
<div class="fc-parts">
<template v-for="(ch, ci) in store.chapters" :key="ch.id">
<section
class="fc-chapter"
:class="{ 'fc-chapter--target': ch.id === store.targetChapterId }"
<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)"
>
<header class="fc-chapter__head">
<span class="fc-chapter__num">{{ ch.chapter_number }}</span>
</label>
<v-text-field
v-model="titleDraft[ch.id]"
:placeholder="`Chapter ${ch.chapter_number}`"
:placeholder="`Untitled — Part ${ch.stated_part ?? ch.chapter_number}`"
density="compact" variant="plain" hide-details
class="fc-chapter__title"
class="fc-part__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>
<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
v-if="ch.source_post?.title" class="fc-part__src"
:title="ch.source_post.title"
>
<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>
<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 />
<div class="fc-chapter__actions">
<v-btn
size="x-small" variant="text" icon="mdi-chevron-up"
title="Move chapter up" :disabled="ci === 0"
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
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>
</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 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>
<div v-else-if="ch.pages.length === 0" class="fc-chapter__empty">
No pages pick this chapter () then add from the right.
<div v-if="ch.is_placeholder" class="fc-part__reserved">
Reserved slot — a part you don't have yet.
</div>
<div v-else class="fc-chapter__pages">
<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="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)" />
<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
v-if="store.gapAfter(ch.id)"
class="fc-gap"
>
<div v-if="store.partGapAfter(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
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 chapters yet add one, then add images from the right.
No parts yet add one, then add pages from the gallery.
</div>
<div class="fc-series__chapter-add">
<div class="fc-parts__add">
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus"
@click="store.createChapter()">Add chapter</v-btn>
@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
@@ -131,29 +180,48 @@
</div>
</div>
<!-- Picker column -->
<div class="fc-series__picker">
<div class="fc-series__pickerhead">
<!-- 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>
<div class="fc-picker__grid">
<div
v-for="img in store.picker" :key="img.id"
class="fc-series__pick"
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>
<div ref="sentinel" class="fc-series__sentinel" />
</div>
</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>
+6 -1
View File
@@ -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
+27
View File
@@ -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
+49
View File
@@ -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"