8ad40da145
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>
307 lines
12 KiB
Vue
307 lines
12 KiB
Vue
<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>
|