Files
FabledCurator/frontend/src/views/SeriesManageView.vue
T
bvandeusen 8ad40da145
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 26s
CI / integration (push) Successful in 3m5s
feat(series): chapter-aware manage view + reader — frontend (FC-6.1)
Completes FC-6.1: the series management UI now works in chapters.

- SeriesManageView: chapters as cards (inline-rename, stated-page range inputs,
  move up/down, merge-into-previous, delete, pick-as-add-target), pages
  drag-reorder WITHIN a chapter, a "gap: N-M missing" badge between chapters
  with a stated-page hole, and Add chapter / Add placeholder. The picker adds
  the selection into the targeted chapter.
- seriesManage store: chapter CRUD + reorderChapters/moveChapter/mergeChapter/
  reorderPages actions; consumes chapters[]/gaps[]; addSelected targets a chapter.
- Reader: page_number is now within-chapter, so anchors switched to a global
  `seq` (reading-order position) — fixes scroll/jump/active collisions across
  chapters — plus chapter-title dividers at each chapter boundary.
- Updated seriesManage.spec to the chaptered store shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:43:10 -04:00

307 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<v-container fluid class="pt-2 pb-6">
<div class="fc-series__head">
<span class="fc-series__name">{{ store.series?.name || 'Series' }}</span>
<span class="fc-series__count">
{{ store.chapters.length }} chapter(s) · {{ store.pageCount }} page(s)
</span>
<v-btn
v-if="store.pageCount > 0"
size="small" variant="tonal" color="accent"
prepend-icon="mdi-book-open"
class="fc-series__read"
@click="$router.push({ name: 'series-read', params: { tagId: store.tagId } })"
>Read</v-btn>
</div>
<div class="fc-series__body">
<!-- Chapters column -->
<div class="fc-series__chapters">
<template v-for="(ch, ci) in store.chapters" :key="ch.id">
<section
class="fc-chapter"
:class="{ 'fc-chapter--target': ch.id === store.targetChapterId }"
>
<header class="fc-chapter__head">
<span class="fc-chapter__num">{{ ch.chapter_number }}</span>
<v-text-field
v-model="titleDraft[ch.id]"
:placeholder="`Chapter ${ch.chapter_number}`"
density="compact" variant="plain" hide-details
class="fc-chapter__title"
@keydown.enter="commitTitle(ch)"
@blur="commitTitle(ch)"
/>
<span v-if="ch.is_placeholder" class="fc-chapter__ph">placeholder</span>
<span v-else class="fc-chapter__pc">{{ ch.pages.length }} pg</span>
<div class="fc-chapter__stated">
<input
class="fc-chapter__num-in" type="number" min="0"
:value="ch.stated_page_start ?? ''" placeholder=""
title="Stated first page"
@change="onStated(ch, 'start', $event)"
>
<span></span>
<input
class="fc-chapter__num-in" type="number" min="0"
:value="ch.stated_page_end ?? ''" placeholder=""
title="Stated last page"
@change="onStated(ch, 'end', $event)"
>
</div>
<div class="fc-chapter__actions">
<v-btn
size="x-small" variant="text" icon="mdi-chevron-up"
title="Move chapter up" :disabled="ci === 0"
@click="store.moveChapter(ch.id, -1)"
/>
<v-btn
size="x-small" variant="text" icon="mdi-chevron-down"
title="Move chapter down"
:disabled="ci === store.chapters.length - 1"
@click="store.moveChapter(ch.id, 1)"
/>
<v-btn
size="x-small" variant="text" icon="mdi-arrow-collapse-up"
title="Merge into previous chapter" :disabled="ci === 0"
@click="store.mergeChapter(ch.id, store.chapters[ci - 1].id)"
/>
<v-btn
size="x-small" variant="text" icon="mdi-target"
:color="ch.id === store.targetChapterId ? 'accent' : undefined"
title="Add picked images here"
@click="store.targetChapterId = ch.id"
/>
<v-btn
size="x-small" variant="text" icon="mdi-delete-outline"
title="Delete chapter (removes its pages from the series)"
@click="store.deleteChapter(ch.id)"
/>
</div>
</header>
<div v-if="ch.is_placeholder" class="fc-chapter__reserved">
Reserved slot a section you don't have yet.
</div>
<div v-else-if="ch.pages.length === 0" class="fc-chapter__empty">
No pages pick this chapter (◎) then add from the right.
</div>
<div v-else class="fc-chapter__pages">
<div
v-for="(p, pi) in ch.pages" :key="p.image_id"
class="fc-page" draggable="true"
@dragstart="drag = { chapterId: ch.id, idx: pi }"
@dragover.prevent
@drop="onPageDrop(ch, pi)"
>
<span class="fc-page__pn">{{ p.page_number }}</span>
<img :src="p.thumbnail_url" alt="" loading="lazy" />
<div class="fc-page__actions">
<v-btn size="x-small" variant="text" icon="mdi-image-frame"
title="Make cover" @click="store.setCover(p.image_id)" />
<v-btn size="x-small" variant="text" icon="mdi-close"
title="Remove" @click="store.remove(p.image_id)" />
</div>
</div>
</div>
</section>
<div
v-if="store.gapAfter(ch.id)"
class="fc-gap"
>
<v-icon size="x-small">mdi-alert-outline</v-icon>
Gap: pages {{ store.gapAfter(ch.id).start }}{{ store.gapAfter(ch.id).end }} missing
</div>
</template>
<div v-if="store.chapters.length === 0" class="fc-series__empty">
No chapters yet add one, then add images from the right.
</div>
<div class="fc-series__chapter-add">
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus"
@click="store.createChapter()">Add chapter</v-btn>
<v-btn size="small" variant="text" prepend-icon="mdi-bookmark-outline"
@click="store.createChapter({ isPlaceholder: true })">
Add placeholder
</v-btn>
</div>
</div>
<!-- Picker column -->
<div class="fc-series__picker">
<div class="fc-series__pickerhead">
<span>{{ store.pickerSelection.length }} selected</span>
<v-btn
size="small" color="accent" variant="flat"
:disabled="store.pickerSelection.length === 0"
@click="store.addSelected()"
>Add to {{ targetLabel }}</v-btn>
</div>
<div class="fc-series__pickergrid">
<div
v-for="img in store.picker" :key="img.id"
class="fc-series__pick"
:class="{ on: store.pickerSelection.includes(img.id) }"
@click="store.togglePick(img.id)"
>
<img :src="img.thumbnail_url" alt="" loading="lazy" />
</div>
</div>
<div ref="sentinel" class="fc-series__sentinel" />
</div>
</div>
</v-container>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
const route = useRoute()
const store = useSeriesManageStore()
const sentinel = ref(null)
const drag = ref(null) // { chapterId, idx }
const titleDraft = reactive({}) // chapterId -> draft string
// Keep local title drafts in sync with the loaded chapters. Edits commit on
// blur/Enter, which refreshes and resets the draft to the saved value.
watch(() => store.chapters, (chs) => {
Object.keys(titleDraft).forEach(k => delete titleDraft[k])
for (const c of chs) titleDraft[c.id] = c.title || ''
}, { immediate: true })
const targetLabel = computed(() => {
const ch = store.chapters.find(c => c.id === store.targetChapterId)
if (!ch) return 'chapter'
return ch.title || `Chapter ${ch.chapter_number}`
})
function commitTitle(ch) {
const v = (titleDraft[ch.id] || '').trim()
if (v === (ch.title || '')) return
store.renameChapter(ch.id, v || null)
}
function onStated(ch, which, ev) {
const raw = ev.target.value
const n = raw === '' ? null : parseInt(raw, 10)
const start = which === 'start' ? n : ch.stated_page_start
const end = which === 'end' ? n : ch.stated_page_end
store.setChapterStated(ch.id, start, end)
}
function onPageDrop(chapter, toIdx) {
if (!drag.value || drag.value.chapterId !== chapter.id) { drag.value = null; return }
if (drag.value.idx === toIdx) { drag.value = null; return }
const ordered = moveItem(
chapter.pages.map(p => p.image_id), drag.value.idx, toIdx
)
drag.value = null
store.reorderPages(chapter.id, ordered)
}
useInfiniteScroll(sentinel, () => store.loadPicker())
onMounted(async () => {
await store.load(parseInt(route.params.tagId, 10))
await store.loadPicker(true)
})
</script>
<style scoped>
.fc-series__head {
display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px;
}
.fc-series__name { font-family: 'Fraunces', Georgia, serif; font-size: 22px; }
.fc-series__count {
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-series__body { display: flex; gap: 16px; align-items: flex-start; }
.fc-series__chapters {
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px;
}
.fc-chapter {
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 8px; padding: 8px 10px;
background: rgb(var(--v-theme-surface));
}
.fc-chapter--target { border-color: rgb(var(--v-theme-accent), 0.7); }
.fc-chapter__head { display: flex; align-items: center; gap: 8px; }
.fc-chapter__num {
flex: 0 0 auto; min-width: 22px; height: 22px; border-radius: 4px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 12px; font-variant-numeric: tabular-nums;
background: rgb(var(--v-theme-accent), 0.16);
color: rgb(var(--v-theme-accent));
}
.fc-chapter__title { flex: 1; min-width: 0; }
.fc-chapter__ph {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-chapter__pc {
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
font-variant-numeric: tabular-nums;
}
.fc-chapter__stated { display: inline-flex; align-items: center; gap: 4px; }
.fc-chapter__num-in {
width: 42px; text-align: center; font-size: 12px;
background: rgb(var(--v-theme-surface-light));
border: 1px solid transparent; border-radius: 4px; padding: 2px 4px;
color: rgb(var(--v-theme-on-surface));
}
.fc-chapter__num-in:focus { outline: none; border-color: rgb(var(--v-theme-accent)); }
.fc-chapter__actions { flex: 0 0 auto; display: inline-flex; }
.fc-chapter__reserved, .fc-chapter__empty {
padding: 14px; text-align: center; font-size: 13px;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-chapter__pages { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
.fc-page {
display: flex; align-items: center; gap: 10px; padding: 6px;
background: rgb(var(--v-theme-surface-light)); border-radius: 6px; cursor: grab;
}
.fc-page img { width: 56px; height: 56px; object-fit: cover; border-radius: 4px; }
.fc-page__pn {
width: 26px; text-align: center; font-variant-numeric: tabular-nums;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-page__actions { margin-left: auto; }
.fc-gap {
display: flex; align-items: center; gap: 6px; padding: 4px 10px;
font-size: 12px; color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
}
.fc-series__chapter-add { display: flex; gap: 8px; margin-top: 4px; }
.fc-series__empty {
padding: 32px; text-align: center; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-series__picker { flex: 1; min-width: 0; }
.fc-series__pickerhead {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px;
}
.fc-series__pickergrid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 6px;
}
.fc-series__pick {
cursor: pointer; aspect-ratio: 1; overflow: hidden;
border-radius: 4px; outline: 2px solid transparent;
}
.fc-series__pick img { width: 100%; height: 100%; object-fit: cover; }
.fc-series__pick.on { outline-color: rgb(var(--v-theme-accent)); }
.fc-series__sentinel { height: 40px; }
@media (max-width: 900px) {
.fc-series__body { flex-direction: column; }
}
</style>