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>
This commit is contained in:
2026-06-07 20:29:10 -04:00
parent 7309d1d6d4
commit 978959bdc4
8 changed files with 523 additions and 184 deletions
+19 -6
View File
@@ -16,8 +16,9 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
const tagId = ref(null)
const series = ref(null)
const chapters = ref([]) // [{id, chapter_number, title, is_placeholder, stated_page_start/end, pages:[...]}]
const gaps = ref([]) // [{after_chapter_id, start, end}]
const chapters = ref([]) // [{id, chapter_number, stated_part, title, is_placeholder, stated_page_start/end, source_post, pages:[...]}]
const gaps = ref([]) // missing-page gaps: [{after_chapter_id, start, end}]
const partGaps = ref([]) // missing-Part gaps: [{after_chapter_id, start, end}]
const pageCount = ref(0)
const targetChapterId = ref(null) // which chapter the picker adds into
const picker = ref([]) // gallery scroll results
@@ -33,6 +34,7 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
series.value = body.series
chapters.value = body.chapters || []
gaps.value = body.gaps || []
partGaps.value = body.part_gaps || []
pageCount.value = (body.pages || []).length
// Keep a valid add-target: the selected chapter, else the first one.
const ids = chapters.value.map(c => c.id)
@@ -52,6 +54,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
return gaps.value.find(g => g.after_chapter_id === chapterId) || null
}
function partGapAfter(chapterId) {
return partGaps.value.find(g => g.after_chapter_id === chapterId) || null
}
// ---- chapters ----
async function createChapter({ title = null, isPlaceholder = false } = {}) {
await api.post(`/api/series/${tagId.value}/chapters`, {
@@ -67,6 +73,13 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
await refresh()
}
async function setChapterPart(chapterId, part) {
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
body: { stated_part: part }
})
await refresh()
}
async function setChapterStated(chapterId, start, end) {
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
body: { stated_page_start: start, stated_page_end: end }
@@ -151,11 +164,11 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
}
return {
tagId, series, chapters, gaps, pageCount, targetChapterId,
tagId, series, chapters, gaps, partGaps, pageCount, targetChapterId,
picker, pickerCursor, pickerSelection, loading,
load, refresh, gapAfter,
createChapter, renameChapter, setChapterStated, reorderChapters,
moveChapter, deleteChapter, mergeChapter, reorderPages,
load, refresh, gapAfter, partGapAfter,
createChapter, renameChapter, setChapterPart, setChapterStated,
reorderChapters, moveChapter, deleteChapter, mergeChapter, reorderPages,
loadPicker, togglePick, addSelected, remove, setCover
}
})
+334 -177
View File
@@ -1,159 +1,227 @@
<template>
<v-container fluid class="pt-2 pb-6">
<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 }} chapter(s) · {{ store.pageCount }} page(s)
{{ 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"
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>
<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>
<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>
<!-- 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>
<div class="fc-chapter__actions">
<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
size="x-small" variant="text" icon="mdi-chevron-up"
title="Move chapter up" :disabled="ci === 0"
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-btn
size="x-small" variant="text" icon="mdi-chevron-down"
title="Move chapter down"
<v-list-item
prepend-icon="mdi-chevron-down" title="Move 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"
<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-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-divider />
<v-list-item
prepend-icon="mdi-delete-outline" title="Delete part"
base-color="error"
@click="confirmDelete(ch)"
/>
<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>
</v-list>
</v-menu>
</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 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>
</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 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 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 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>
<!-- Picker column -->
<div class="fc-series__picker">
<div class="fc-series__pickerhead">
<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 {{ targetLabel }}</v-btn>
>Add to part</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>
<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>
@@ -167,20 +235,29 @@ const route = useRoute()
const store = useSeriesManageStore()
const sentinel = ref(null)
const drag = ref(null) // { chapterId, idx }
const titleDraft = reactive({}) // chapterId -> draft string
const titleDraft = reactive({}) // chapterId -> draft title
const partDraft = reactive({}) // chapterId -> draft stated_part (string)
const pickerOpen = ref(false)
// 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.
// 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) => {
Object.keys(titleDraft).forEach(k => delete titleDraft[k])
for (const c of chs) titleDraft[c.id] = c.title || ''
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 targetLabel = computed(() => {
const ch = store.chapters.find(c => c.id === store.targetChapterId)
if (!ch) return 'chapter'
return ch.title || `Chapter ${ch.chapter_number}`
})
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()
@@ -188,6 +265,17 @@ function commitTitle(ch) {
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)
@@ -206,6 +294,20 @@ function onPageDrop(chapter, toIdx) {
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 () => {
@@ -216,91 +318,146 @@ onMounted(async () => {
<style scoped>
.fc-series__head {
display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px;
display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;
}
.fc-series__name { font-family: 'Fraunces', Georgia, serif; font-size: 22px; }
.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__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-series__hint {
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 18px; max-width: 760px;
}
.fc-chapter {
/* 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: 8px; padding: 8px 10px;
border-radius: 10px; padding: 12px 14px;
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);
.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-chapter__title { flex: 1; min-width: 0; }
.fc-chapter__ph {
.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-chapter__pc {
.fc-part__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;
.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-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;
.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));
}
.fc-chapter__pages { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
/* Big page grid */
.fc-part__pages {
display: grid; gap: 10px; margin-top: 10px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.fc-page {
display: flex; align-items: center; gap: 10px; padding: 6px;
background: rgb(var(--v-theme-surface-light)); border-radius: 6px; cursor: grab;
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 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));
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 { margin-left: auto; }
.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: 4px 10px;
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__chapter-add { display: flex; gap: 8px; margin-top: 4px; }
.fc-parts__add { display: flex; gap: 8px; margin-top: 4px; }
.fc-series__empty {
padding: 32px; text-align: center; color: rgb(var(--v-theme-on-surface-variant));
padding: 40px; text-align: center; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-series__picker { flex: 1; min-width: 0; }
.fc-series__pickerhead {
/* 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;
margin-bottom: 8px;
font-family: 'Fraunces', Georgia, serif; font-size: 18px;
}
.fc-series__pickergrid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 6px;
.fc-picker__bar {
display: flex; align-items: center; justify-content: space-between;
margin-top: 10px; font-size: 13px;
}
.fc-series__pick {
cursor: pointer; aspect-ratio: 1; overflow: hidden;
border-radius: 4px; outline: 2px solid transparent;
.fc-picker__grid {
display: grid; gap: 6px; padding: 12px;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
}
.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; }
.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>