feat(series): flat series sequence + cosmetic chapter dividers (#789 Phase 1)
Reframe a series from "ordered chapters that own pages" to ONE flat, series-global ordered run of pages with optional cosmetic chapter DIVIDERS over it. A chapter no longer wraps content — it's a labeled divider anchored to the page that begins it; a page's chapter is derived as the nearest preceding divider. This is what lets installments assembled from multiple sources sit in one continuous, correctly-numbered sequence (operator's Goblin Juice case). - migration 0047: flatten each series to a series-global page_number (preserving today's reading order); convert each existing chapter to a divider anchored at its first page (keeping title/stated_part); drop series_page.chapter_id; reshape series_chapter (anchor_page_id UNIQUE FK, drop chapter_number/is_placeholder/stated_page_start/end). Loss-safe for content; drops empty placeholder chapters + a redundant page-1 divider. - series_page: page_number is now the series-global order; no chapter_id. - series_chapter: anchored divider (anchor_page_id, title, stated_part). - series_service: flat list_pages (one run + derived dividers + per-page source_post + part_gaps), series-wide reorder/renumber, divider CRUD (create/update/move/delete); retired per-chapter reorder/merge/placement. - api/tags: drop chapter_id from add; /chapters endpoints are divider create/update/delete (removed chapter reorder/merge/page-reorder). - series_match_service: series "end" reads max(series_page.stated_page); accept appends via add_post. tag_service series-merge appends src's pages after tgt's max so the merged series stays one clean run. - frontend: seriesManage store + SeriesManageView → one continuous drag-reorder grid with inline divider bars + series-global page numbers; reader walks the flat run, headings from dividers; PostSeriesMenu copy. - tests reworked across the series suite for the divider model. Phase 2 (pending staging for add-from-post) is separate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,9 +9,13 @@
|
||||
@click="renameOpen = true"
|
||||
/>
|
||||
<span class="fc-series__count">
|
||||
{{ store.chapters.length }} part(s) · {{ store.pageCount }} page(s)
|
||||
{{ store.dividers.length }} chapter(s) · {{ store.pageCount }} page(s)
|
||||
</span>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
size="small" variant="tonal" prepend-icon="mdi-image-plus"
|
||||
@click="pickerOpen = true"
|
||||
>Add pages</v-btn>
|
||||
<v-btn
|
||||
v-if="store.pageCount > 0"
|
||||
size="small" variant="tonal" color="accent"
|
||||
@@ -21,161 +25,103 @@
|
||||
</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.
|
||||
One continuous series. <strong>Drag any page</strong> to set its order —
|
||||
the numbers are the page's place in the whole series. Use a page's
|
||||
<strong>⋮ → Start a chapter here</strong> to drop a labeled divider before
|
||||
it. New pages from <strong>Add pages</strong> append to the end, ready to
|
||||
be dragged into place.
|
||||
</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>
|
||||
<!-- One continuous page run; chapter dividers render inline before their
|
||||
anchor page. -->
|
||||
<div v-if="store.pages.length" class="fc-run">
|
||||
<template v-for="(p, pi) in store.pages" :key="p.image_id">
|
||||
<!-- chapter divider bar, if one is anchored at this page -->
|
||||
<template v-if="store.dividerAt(p.image_id)">
|
||||
<div class="fc-divider">
|
||||
<v-icon size="small" class="fc-divider__icon">mdi-format-section</v-icon>
|
||||
<label class="fc-divider__partfield" title="Part number for this chapter">
|
||||
<span class="fc-divider__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)"
|
||||
class="fc-divider__partnum" type="number" min="1"
|
||||
:value="partDraft[store.dividerAt(p.image_id).id] ?? ''"
|
||||
placeholder="—"
|
||||
@input="partDraft[store.dividerAt(p.image_id).id] = $event.target.value"
|
||||
@keydown.enter.prevent="commitPart(store.dividerAt(p.image_id))"
|
||||
@blur="commitPart(store.dividerAt(p.image_id))"
|
||||
>
|
||||
</label>
|
||||
|
||||
<v-text-field
|
||||
v-model="titleDraft[ch.id]"
|
||||
:placeholder="`Untitled — Part ${ch.stated_part ?? ch.chapter_number}`"
|
||||
:model-value="titleDraft[store.dividerAt(p.image_id).id]"
|
||||
placeholder="Untitled chapter"
|
||||
density="compact" variant="plain" hide-details
|
||||
class="fc-part__title"
|
||||
@keydown.enter="commitTitle(ch)"
|
||||
@blur="commitTitle(ch)"
|
||||
class="fc-divider__title"
|
||||
@update:model-value="titleDraft[store.dividerAt(p.image_id).id] = $event"
|
||||
@keydown.enter="commitTitle(store.dividerAt(p.image_id))"
|
||||
@blur="commitTitle(store.dividerAt(p.image_id))"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<KebabMenu size="small" label="Part actions">
|
||||
<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)"
|
||||
/>
|
||||
</KebabMenu>
|
||||
</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>
|
||||
size="x-small" variant="text" icon="mdi-close"
|
||||
title="Remove this chapter divider"
|
||||
@click="store.deleteDivider(store.dividerAt(p.image_id).id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="ch.is_placeholder" class="fc-part__reserved">
|
||||
Reserved slot — a part you don't have yet.
|
||||
<div
|
||||
v-if="store.partGapAfter(store.dividerAt(p.image_id).id)"
|
||||
class="fc-gap"
|
||||
>
|
||||
<v-icon size="x-small">mdi-alert-outline</v-icon>
|
||||
Missing
|
||||
<template v-if="gapText(p.image_id).single">
|
||||
Part {{ gapText(p.image_id).start }}
|
||||
</template>
|
||||
<template v-else>
|
||||
Parts {{ gapText(p.image_id).start }}–{{ gapText(p.image_id).end }}
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="ch.pages.length === 0" class="fc-part__empty">
|
||||
No pages yet.
|
||||
</template>
|
||||
|
||||
<!-- the page -->
|
||||
<div
|
||||
class="fc-page" draggable="true"
|
||||
:class="{ 'fc-page--dragging': drag === pi }"
|
||||
@dragstart="drag = pi"
|
||||
@dragend="drag = null"
|
||||
@dragover.prevent
|
||||
@drop="onDrop(pi)"
|
||||
>
|
||||
<span class="fc-page__pn">{{ p.page_number }}</span>
|
||||
<img :src="p.thumbnail_url" alt="" loading="lazy" />
|
||||
<span
|
||||
v-if="p.source_post?.title" class="fc-page__src"
|
||||
:title="`From: ${p.source_post.title}`"
|
||||
>
|
||||
<v-icon size="x-small">mdi-link-variant</v-icon>
|
||||
{{ p.source_post.title }}
|
||||
</span>
|
||||
<div class="fc-page__actions">
|
||||
<v-btn
|
||||
size="small" variant="text" color="accent"
|
||||
prepend-icon="mdi-image-plus" @click="openPicker(ch.id)"
|
||||
>Add pages</v-btn>
|
||||
size="x-small" variant="flat" icon="mdi-image-frame"
|
||||
title="Make series cover" @click="store.setCover(p.image_id)"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="!store.dividerAt(p.image_id)"
|
||||
size="x-small" variant="flat" icon="mdi-format-section"
|
||||
title="Start a chapter here" @click="store.createDivider(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 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>
|
||||
|
||||
<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 v-else class="fc-series__empty">
|
||||
No pages yet — use <strong>Add pages</strong> to pull images from the
|
||||
gallery into this series.
|
||||
</div>
|
||||
|
||||
<!-- Picker slide-over -->
|
||||
@@ -189,19 +135,13 @@
|
||||
<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>
|
||||
>Add to series</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fc-picker__grid">
|
||||
@@ -232,19 +172,18 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
|
||||
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||
import KebabMenu from '../components/common/KebabMenu.vue'
|
||||
import TagRenameDialog from '../components/modal/TagRenameDialog.vue'
|
||||
|
||||
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 drag = ref(null) // index in the flat pages run being dragged
|
||||
const titleDraft = reactive({}) // dividerId -> draft title
|
||||
const partDraft = reactive({}) // dividerId -> draft stated_part (string)
|
||||
const pickerOpen = ref(false)
|
||||
const renameOpen = ref(false)
|
||||
|
||||
@@ -255,73 +194,47 @@ function onRenamed(updated) {
|
||||
if (store.series && updated?.name) store.series.name = updated.name
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
// Keep local divider drafts in sync with loaded dividers; edits commit on
|
||||
// blur/Enter, which refreshes and resets the draft to the saved value.
|
||||
watch(() => store.dividers, (divs) => {
|
||||
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)
|
||||
for (const d of divs) {
|
||||
titleDraft[d.id] = d.title || ''
|
||||
partDraft[d.id] = d.stated_part == null ? '' : String(d.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 commitTitle(d) {
|
||||
const v = (titleDraft[d.id] || '').trim()
|
||||
if (v === (d.title || '')) return
|
||||
store.renameDivider(d.id, v || null)
|
||||
}
|
||||
|
||||
function commitPart(ch) {
|
||||
const raw = (partDraft[ch.id] ?? '').trim()
|
||||
function commitPart(d) {
|
||||
const raw = (partDraft[d.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)
|
||||
partDraft[d.id] = d.stated_part == null ? '' : String(d.stated_part)
|
||||
return
|
||||
}
|
||||
if (next === (ch.stated_part ?? null)) return
|
||||
store.setChapterPart(ch.id, next)
|
||||
if (next === (d.stated_part ?? null)) return
|
||||
store.setDividerPart(d.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 gapText(anchorImageId) {
|
||||
const d = store.dividerAt(anchorImageId)
|
||||
const g = d ? store.partGapAfter(d.id) : null
|
||||
return { start: g?.start, end: g?.end, single: g && g.start === g.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 }
|
||||
function onDrop(toIdx) {
|
||||
if (drag.value === null || drag.value === toIdx) { drag.value = null; return }
|
||||
const ordered = moveItem(
|
||||
chapter.pages.map(p => p.image_id), drag.value.idx, toIdx
|
||||
store.pages.map(p => p.image_id), drag.value, 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
|
||||
store.reorder(ordered)
|
||||
}
|
||||
|
||||
useInfiniteScroll(sentinel, () => store.loadPicker())
|
||||
@@ -342,76 +255,46 @@ onMounted(async () => {
|
||||
}
|
||||
.fc-series__hint {
|
||||
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
margin-bottom: 18px; max-width: 760px;
|
||||
margin-bottom: 18px; max-width: 820px;
|
||||
}
|
||||
|
||||
/* 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));
|
||||
/* One continuous page run with inline divider bars. */
|
||||
.fc-run {
|
||||
display: grid; gap: 10px; max-width: 1100px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
.fc-part__head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
|
||||
.fc-part__partfield {
|
||||
.fc-divider {
|
||||
grid-column: 1 / -1;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-top: 6px; padding: 6px 12px;
|
||||
border-left: 3px solid rgb(var(--v-theme-accent));
|
||||
background: rgb(var(--v-theme-accent), 0.10);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
.fc-divider__icon { color: rgb(var(--v-theme-accent)); }
|
||||
.fc-divider__partfield {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: rgb(var(--v-theme-accent), 0.12);
|
||||
background: rgb(var(--v-theme-accent), 0.14);
|
||||
border: 1px solid rgb(var(--v-theme-accent), 0.35);
|
||||
border-radius: 8px; padding: 4px 8px;
|
||||
border-radius: 8px; padding: 2px 8px;
|
||||
}
|
||||
.fc-part__partlabel {
|
||||
font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
.fc-divider__partlabel {
|
||||
font-size: 11px; 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;
|
||||
.fc-divider__partnum {
|
||||
width: 42px; text-align: center; font-size: 16px; 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-divider__partnum:focus { outline: none; }
|
||||
.fc-divider__partnum::placeholder {
|
||||
color: rgb(var(--v-theme-on-surface-variant)); opacity: 0.6;
|
||||
}
|
||||
.fc-divider__title { flex: 1 1 200px; min-width: 120px; font-size: 15px; }
|
||||
|
||||
.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));
|
||||
}
|
||||
/* Big page tiles */
|
||||
.fc-page {
|
||||
position: relative; border-radius: 8px; overflow: hidden; cursor: grab;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
@@ -429,6 +312,12 @@ onMounted(async () => {
|
||||
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__src {
|
||||
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
|
||||
display: flex; align-items: center; gap: 4px; padding: 3px 6px;
|
||||
font-size: 11px; color: #fff; background: rgba(0, 0, 0, 0.55);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fc-page__actions {
|
||||
position: absolute; top: 4px; right: 4px; z-index: 2;
|
||||
display: flex; gap: 2px; opacity: 0; transition: opacity 0.12s;
|
||||
@@ -439,12 +328,13 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.fc-gap {
|
||||
grid-column: 1 / -1;
|
||||
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));
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
/* Picker slide-over */
|
||||
|
||||
Reference in New Issue
Block a user