Merge pull request 'Browse search + series numbering rework + kebab fix' (#98) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 2m50s
CI / integration (push) Successful in 3m16s

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