013b9d7f06
Replaces the auto-renumbered 1..N position key with operator-OWNED page numbers: sparse, gaps allowed, editable, never auto-renumbered. Order follows the numbers; unnumbered pages sort to the tail. This is the fix for the model that clobbered hand-set numbers on the flatten — numbers are now data, not a derived sequence. - series_service: drop the renumber-on-reorder/remove; order by page_number NULLS LAST; new set_page_number(image_id, n|None); list_pages returns `gaps` (one entry per missing-number run) + each pending group's parsed `start_page`; set_cover renumbers below the current min; place_pending(image_ids, start_page) numbers placed pages sequentially from the start (drop junk first → numbers line up); add_post stamps the parsed start on staged pages. - api/tags: POST /series/<id>/pages/number (set one page's number); /pending/ place takes start_page; removed /reorder. - frontend: per-card editable number input; one gap block per gap with drop-on-edge to assign the adjacent number (middle → type); append drop zone; pending tray gets a "from page N" field + "Place from page N". - tests reworked: sparse numbers + gaps, place-from-start, set-page-number route. No migration; nothing destructive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
5.5 KiB
JavaScript
162 lines
5.5 KiB
JavaScript
import { defineStore } from 'pinia'
|
|
import { toast } from '../utils/toast.js'
|
|
import { ref } from 'vue'
|
|
import { useApi } from '../composables/useApi.js'
|
|
|
|
// Pure, unit-tested: move arr[from] to index `to`, returning a new array.
|
|
export function moveItem(arr, from, to) {
|
|
const next = arr.slice()
|
|
const [x] = next.splice(from, 1)
|
|
next.splice(to, 0, x)
|
|
return next
|
|
}
|
|
|
|
// FC-6.x: a series is ONE flat, series-global ordered run of pages, with
|
|
// optional cosmetic chapter DIVIDERS anchored to the page that begins them.
|
|
export const useSeriesManageStore = defineStore('seriesManage', () => {
|
|
const api = useApi()
|
|
|
|
const tagId = ref(null)
|
|
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 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
|
|
const pickerCursor = ref(null)
|
|
const pickerSelection = ref([]) // image ids
|
|
const loading = ref(false)
|
|
|
|
async function load(id) {
|
|
tagId.value = id
|
|
loading.value = true
|
|
try {
|
|
const body = await api.get(`/api/series/${id}/pages`)
|
|
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
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function refresh() {
|
|
if (tagId.value != null) await load(tagId.value)
|
|
}
|
|
|
|
// The divider anchored at a given page (shown as a chapter bar before it).
|
|
function dividerAt(imageId) {
|
|
return dividers.value.find(d => d.anchor_image_id === imageId) || null
|
|
}
|
|
|
|
function partGapAfter(dividerId) {
|
|
return partGaps.value.find(g => g.after_divider_id === dividerId) || null
|
|
}
|
|
|
|
// ---- 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] }
|
|
})
|
|
await refresh()
|
|
}
|
|
|
|
async function setCover(imageId) {
|
|
await api.post(`/api/series/${tagId.value}/cover`, {
|
|
body: { image_id: imageId }
|
|
})
|
|
await refresh()
|
|
}
|
|
|
|
// ---- chapter dividers ----
|
|
async function createDivider(anchorImageId, { title = null, statedPart = null } = {}) {
|
|
await api.post(`/api/series/${tagId.value}/chapters`, {
|
|
body: { anchor_image_id: anchorImageId, title, stated_part: statedPart }
|
|
})
|
|
await refresh()
|
|
}
|
|
|
|
async function renameDivider(dividerId, title) {
|
|
await api.patch(`/api/series/${tagId.value}/chapters/${dividerId}`, {
|
|
body: { title }
|
|
})
|
|
await refresh()
|
|
}
|
|
|
|
async function setDividerPart(dividerId, part) {
|
|
await api.patch(`/api/series/${tagId.value}/chapters/${dividerId}`, {
|
|
body: { stated_part: part }
|
|
})
|
|
await refresh()
|
|
}
|
|
|
|
async function deleteDivider(dividerId) {
|
|
await api.delete(`/api/series/${tagId.value}/chapters/${dividerId}`)
|
|
await refresh()
|
|
toast({ text: 'Chapter divider removed', type: 'success' })
|
|
}
|
|
|
|
// ---- pending staging (add-from-post) ----
|
|
// 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, start_page: startPage }
|
|
})
|
|
await refresh()
|
|
toast({ text: 'Pages placed into the series', type: 'success' })
|
|
}
|
|
|
|
// ---- picker / add pages ----
|
|
async function loadPicker(reset = false) {
|
|
if (reset) { picker.value = []; pickerCursor.value = null }
|
|
const params = { limit: 50 }
|
|
if (pickerCursor.value) params.cursor = pickerCursor.value
|
|
const body = await api.get('/api/gallery/scroll', { params })
|
|
picker.value.push(...body.images)
|
|
pickerCursor.value = body.next_cursor
|
|
}
|
|
|
|
function togglePick(imageId) {
|
|
const i = pickerSelection.value.indexOf(imageId)
|
|
if (i === -1) pickerSelection.value.push(imageId)
|
|
else pickerSelection.value.splice(i, 1)
|
|
}
|
|
|
|
async function addSelected() {
|
|
if (pickerSelection.value.length === 0) return
|
|
await api.post(`/api/series/${tagId.value}/pages`, {
|
|
body: { image_ids: pickerSelection.value }
|
|
})
|
|
pickerSelection.value = []
|
|
await refresh()
|
|
toast({ text: 'Added to series', type: 'success' })
|
|
}
|
|
|
|
return {
|
|
tagId, series, pages, dividers, gaps, pending, partGaps, pageCount,
|
|
picker, pickerCursor, pickerSelection, loading,
|
|
load, refresh, dividerAt, partGapAfter, gapAfter,
|
|
setPageNumber, remove, setCover, placePending,
|
|
createDivider, renameDivider, setDividerPart, deleteDivider,
|
|
loadPicker, togglePick, addSelected
|
|
}
|
|
})
|