Files
FabledCurator/frontend/src/views/SeriesManageView.vue
T
bvandeusen 978959bdc4 feat(series): manage-view redesign — big pages, editable Part #, slide-over picker (FC-6.4)
Operator feedback: thumbnails too small to judge order, no obvious way to mark
'this installment is Part 2', and the permanent two-pane picker was busy and
competed with the ordering work.

- Full-width parts, each a card with a big page grid (150px, contain so whole
  pages are visible) and drag-to-reorder; positional page number as a badge.
- Editable Part # (hero field) backed by new series_chapter.stated_part —
  separate from the auto-managed chapter_number, mirroring the page_number vs
  stated_page split so reorder/delete renumbering can't wipe a hand-set part.
  Missing-Part hints when consecutive parts' stated_part jump >1.
- Each part labels its source post (derived from pages' primary_post_id) and
  shows the printed-page range with clear labels.
- Picker demoted to an on-demand right slide-over ('Add pages') with a target-
  part selector; part actions (move/merge/delete) collapsed into an overflow ⋮.

alembic 0042 adds series_chapter.stated_part (nullable int).

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

464 lines
17 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-10 fc-series">
<div class="fc-series__head">
<span class="fc-series__name">{{ store.series?.name || 'Series' }}</span>
<span class="fc-series__count">
{{ store.chapters.length }} part(s) · {{ store.pageCount }} page(s)
</span>
<v-spacer />
<v-btn
v-if="store.pageCount > 0"
size="small" variant="tonal" color="accent"
prepend-icon="mdi-book-open"
@click="$router.push({ name: 'series-read', params: { tagId: store.tagId } })"
>Read</v-btn>
</div>
<p class="fc-series__hint">
Each part is one installment drag pages to set their order, and set the
<strong>Part #</strong> to mark where it falls in the story. Use
<strong>Add pages</strong> to pull images from the gallery into the part
you're working on.
</p>
<!-- Parts, full width -->
<div class="fc-parts">
<template v-for="(ch, ci) in store.chapters" :key="ch.id">
<section class="fc-part">
<header class="fc-part__head">
<label class="fc-part__partfield" :title="'Part number for this installment'">
<span class="fc-part__partlabel">Part</span>
<input
class="fc-part__partnum" type="number" min="1"
:value="partDraft[ch.id] ?? ''"
:placeholder="String(ch.chapter_number)"
@input="partDraft[ch.id] = $event.target.value"
@keydown.enter.prevent="commitPart(ch)"
@blur="commitPart(ch)"
>
</label>
<v-text-field
v-model="titleDraft[ch.id]"
:placeholder="`Untitled — Part ${ch.stated_part ?? ch.chapter_number}`"
density="compact" variant="plain" hide-details
class="fc-part__title"
@keydown.enter="commitTitle(ch)"
@blur="commitTitle(ch)"
/>
<span
v-if="ch.source_post?.title" class="fc-part__src"
:title="ch.source_post.title"
>
<v-icon size="x-small">mdi-link-variant</v-icon>
{{ ch.source_post.title }}
</span>
<span v-if="ch.is_placeholder" class="fc-part__badge">placeholder</span>
<span v-else class="fc-part__pc">{{ ch.pages.length }} pg</span>
<v-spacer />
<v-btn
v-if="!ch.is_placeholder"
size="small" variant="tonal" color="accent"
prepend-icon="mdi-image-plus"
@click="openPicker(ch.id)"
>Add pages</v-btn>
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn
v-bind="props" size="small" variant="text"
icon="mdi-dots-vertical" title="Part actions"
/>
</template>
<v-list density="compact">
<v-list-item
prepend-icon="mdi-chevron-up" title="Move up"
:disabled="ci === 0"
@click="store.moveChapter(ch.id, -1)"
/>
<v-list-item
prepend-icon="mdi-chevron-down" title="Move down"
:disabled="ci === store.chapters.length - 1"
@click="store.moveChapter(ch.id, 1)"
/>
<v-list-item
prepend-icon="mdi-arrow-collapse-up" title="Merge into previous"
:disabled="ci === 0"
@click="store.mergeChapter(ch.id, store.chapters[ci - 1].id)"
/>
<v-divider />
<v-list-item
prepend-icon="mdi-delete-outline" title="Delete part"
base-color="error"
@click="confirmDelete(ch)"
/>
</v-list>
</v-menu>
</header>
<div class="fc-part__statedrow">
<span class="fc-part__statedlabel">Printed pages</span>
<input
class="fc-part__statedin" type="number" min="0"
:value="ch.stated_page_start ?? ''" placeholder="start"
@change="onStated(ch, 'start', $event)"
>
<span class="fc-part__dash"></span>
<input
class="fc-part__statedin" type="number" min="0"
:value="ch.stated_page_end ?? ''" placeholder="end"
@change="onStated(ch, 'end', $event)"
>
<span class="fc-part__statedhelp">
optional — the page numbers printed in this installment
</span>
</div>
<div v-if="ch.is_placeholder" class="fc-part__reserved">
Reserved slot — a part you don't have yet.
</div>
<div v-else-if="ch.pages.length === 0" class="fc-part__empty">
No pages yet.
<v-btn
size="small" variant="text" color="accent"
prepend-icon="mdi-image-plus" @click="openPicker(ch.id)"
>Add pages</v-btn>
</div>
<div v-else class="fc-part__pages">
<div
v-for="(p, pi) in ch.pages" :key="p.image_id"
class="fc-page" draggable="true"
:class="{ 'fc-page--dragging': drag && drag.chapterId === ch.id && drag.idx === pi }"
@dragstart="drag = { chapterId: ch.id, idx: pi }"
@dragend="drag = null"
@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="flat" icon="mdi-image-frame"
title="Make series cover" @click="store.setCover(p.image_id)" />
<v-btn size="x-small" variant="flat" icon="mdi-close"
title="Remove from series" @click="store.remove(p.image_id)" />
</div>
</div>
</div>
</section>
<div v-if="store.partGapAfter(ch.id)" class="fc-gap">
<v-icon size="x-small">mdi-alert-outline</v-icon>
Missing
<template v-if="store.partGapAfter(ch.id).start === store.partGapAfter(ch.id).end">
Part {{ store.partGapAfter(ch.id).start }}
</template>
<template v-else>
Parts {{ store.partGapAfter(ch.id).start }}{{ store.partGapAfter(ch.id).end }}
</template>
</div>
<div v-if="store.gapAfter(ch.id)" class="fc-gap">
<v-icon size="x-small">mdi-alert-outline</v-icon>
Gap: printed 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 parts yet add one, then add pages from the gallery.
</div>
<div class="fc-parts__add">
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus"
@click="store.createChapter()">Add part</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 slide-over -->
<v-navigation-drawer
v-model="pickerOpen" location="right" temporary width="420"
class="fc-picker"
>
<div class="fc-picker__head">
<div class="fc-picker__title">
<span>Add pages</span>
<v-btn size="x-small" variant="text" icon="mdi-close"
@click="pickerOpen = false" />
</div>
<v-select
v-model="store.targetChapterId"
:items="chapterItems" item-title="label" item-value="id"
density="compact" variant="outlined" hide-details
label="Add to part" class="mt-2"
/>
<div class="fc-picker__bar">
<span>{{ store.pickerSelection.length }} selected</span>
<v-btn
size="small" color="accent" variant="flat"
:disabled="store.pickerSelection.length === 0"
@click="store.addSelected()"
>Add to part</v-btn>
</div>
</div>
<div class="fc-picker__grid">
<div
v-for="img in store.picker" :key="img.id"
class="fc-picker__pick"
:class="{ on: store.pickerSelection.includes(img.id) }"
@click="store.togglePick(img.id)"
>
<img :src="img.thumbnail_url" alt="" loading="lazy" />
<v-icon
v-if="store.pickerSelection.includes(img.id)"
class="fc-picker__check" color="accent" size="small"
>mdi-check-circle</v-icon>
</div>
<div ref="sentinel" class="fc-picker__sentinel" />
</div>
</v-navigation-drawer>
</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 title
const partDraft = reactive({}) // chapterId -> draft stated_part (string)
const pickerOpen = ref(false)
// Keep local drafts in sync with loaded chapters. Edits commit on blur/Enter,
// which refreshes and resets the draft to the saved value.
watch(() => store.chapters, (chs) => {
for (const k of Object.keys(titleDraft)) delete titleDraft[k]
for (const k of Object.keys(partDraft)) delete partDraft[k]
for (const c of chs) {
titleDraft[c.id] = c.title || ''
partDraft[c.id] = c.stated_part == null ? '' : String(c.stated_part)
}
}, { immediate: true })
const chapterItems = computed(() =>
store.chapters.map(c => ({
id: c.id,
label: `Part ${c.stated_part ?? c.chapter_number}` +
(c.title ? ` — ${c.title}` : '') +
(c.is_placeholder ? ' (placeholder)' : ` · ${c.pages.length} pg`),
}))
)
function commitTitle(ch) {
const v = (titleDraft[ch.id] || '').trim()
if (v === (ch.title || '')) return
store.renameChapter(ch.id, v || null)
}
function commitPart(ch) {
const raw = (partDraft[ch.id] ?? '').trim()
const next = raw === '' ? null : parseInt(raw, 10)
if (raw !== '' && (Number.isNaN(next) || next < 1)) {
partDraft[ch.id] = ch.stated_part == null ? '' : String(ch.stated_part)
return
}
if (next === (ch.stated_part ?? null)) return
store.setChapterPart(ch.id, next)
}
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)
}
function confirmDelete(ch) {
const label = `Part ${ch.stated_part ?? ch.chapter_number}`
const n = ch.pages.length
const msg = n
? `Delete ${label} and remove its ${n} page(s) from the series?`
: `Delete ${label}?`
if (window.confirm(msg)) store.deleteChapter(ch.id)
}
function openPicker(chapterId) {
store.targetChapterId = chapterId
pickerOpen.value = true
}
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: 4px;
}
.fc-series__name { font-family: 'Fraunces', Georgia, serif; font-size: 24px; }
.fc-series__count {
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-series__hint {
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 18px; max-width: 760px;
}
/* Parts — full width, stacked */
.fc-parts { display: flex; flex-direction: column; gap: 14px; max-width: 1100px; }
.fc-part {
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 10px; padding: 12px 14px;
background: rgb(var(--v-theme-surface));
}
.fc-part__head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.fc-part__partfield {
display: inline-flex; align-items: center; gap: 6px;
background: rgb(var(--v-theme-accent), 0.12);
border: 1px solid rgb(var(--v-theme-accent), 0.35);
border-radius: 8px; padding: 4px 8px;
}
.fc-part__partlabel {
font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em;
color: rgb(var(--v-theme-accent));
}
.fc-part__partnum {
width: 46px; text-align: center; font-size: 18px; font-weight: 600;
font-variant-numeric: tabular-nums;
background: transparent; border: none; color: rgb(var(--v-theme-on-surface));
}
.fc-part__partnum:focus { outline: none; }
.fc-part__partnum::placeholder { color: rgb(var(--v-theme-on-surface-variant)); opacity: 0.6; }
.fc-part__title { flex: 1 1 180px; min-width: 120px; font-size: 15px; }
.fc-part__src {
display: inline-flex; align-items: center; gap: 4px; max-width: 240px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-part__badge {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-part__pc {
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
font-variant-numeric: tabular-nums;
}
.fc-part__statedrow {
display: flex; align-items: center; gap: 6px; margin: 8px 0 2px;
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-part__statedlabel { text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; }
.fc-part__statedin {
width: 56px; 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-part__statedin:focus { outline: none; border-color: rgb(var(--v-theme-accent)); }
.fc-part__dash { opacity: 0.6; }
.fc-part__statedhelp { font-size: 11px; opacity: 0.7; }
.fc-part__reserved, .fc-part__empty {
padding: 18px; text-align: center; font-size: 13px;
color: rgb(var(--v-theme-on-surface-variant));
}
/* Big page grid */
.fc-part__pages {
display: grid; gap: 10px; margin-top: 10px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.fc-page {
position: relative; border-radius: 8px; overflow: hidden; cursor: grab;
background: rgb(var(--v-theme-surface-light));
border: 1px solid rgb(var(--v-theme-surface-light));
}
.fc-page--dragging { opacity: 0.4; }
.fc-page img {
width: 100%; aspect-ratio: 3 / 4; object-fit: contain;
display: block; background: rgb(var(--v-theme-background));
}
.fc-page__pn {
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));
}
.fc-page__actions {
position: absolute; top: 4px; right: 4px; z-index: 2;
display: flex; gap: 2px; opacity: 0; transition: opacity 0.12s;
}
.fc-page:hover .fc-page__actions, .fc-page:focus-within .fc-page__actions { opacity: 1; }
.fc-page__actions :deep(.v-btn) {
background: rgba(0, 0, 0, 0.55); color: #fff;
}
.fc-gap {
display: flex; align-items: center; gap: 6px; padding: 2px 10px;
font-size: 12px; color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
}
.fc-parts__add { display: flex; gap: 8px; margin-top: 4px; }
.fc-series__empty {
padding: 40px; text-align: center; color: rgb(var(--v-theme-on-surface-variant));
}
/* Picker slide-over */
.fc-picker__head {
position: sticky; top: 0; z-index: 1; padding: 12px 14px;
background: rgb(var(--v-theme-surface));
border-bottom: 1px solid rgb(var(--v-theme-surface-light));
}
.fc-picker__title {
display: flex; align-items: center; justify-content: space-between;
font-family: 'Fraunces', Georgia, serif; font-size: 18px;
}
.fc-picker__bar {
display: flex; align-items: center; justify-content: space-between;
margin-top: 10px; font-size: 13px;
}
.fc-picker__grid {
display: grid; gap: 6px; padding: 12px;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
}
.fc-picker__pick {
position: relative; cursor: pointer; aspect-ratio: 1; overflow: hidden;
border-radius: 6px; outline: 2px solid transparent;
}
.fc-picker__pick img { width: 100%; height: 100%; object-fit: cover; }
.fc-picker__pick.on { outline-color: rgb(var(--v-theme-accent)); }
.fc-picker__check {
position: absolute; top: 4px; right: 4px;
background: rgba(0, 0, 0, 0.5); border-radius: 50%;
}
.fc-picker__sentinel { grid-column: 1 / -1; height: 40px; }
</style>