Browse search + series numbering rework + kebab fix #98

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