feat(series): operator-set sparse page numbers + gap blocks (#789 tweak)
Replaces the auto-renumbered 1..N position key with operator-OWNED page numbers: sparse, gaps allowed, editable, never auto-renumbered. Order follows the numbers; unnumbered pages sort to the tail. This is the fix for the model that clobbered hand-set numbers on the flatten — numbers are now data, not a derived sequence. - series_service: drop the renumber-on-reorder/remove; order by page_number NULLS LAST; new set_page_number(image_id, n|None); list_pages returns `gaps` (one entry per missing-number run) + each pending group's parsed `start_page`; set_cover renumbers below the current min; place_pending(image_ids, start_page) numbers placed pages sequentially from the start (drop junk first → numbers line up); add_post stamps the parsed start on staged pages. - api/tags: POST /series/<id>/pages/number (set one page's number); /pending/ place takes start_page; removed /reorder. - frontend: per-card editable number input; one gap block per gap with drop-on-edge to assign the adjacent number (middle → type); append drop zone; pending tray gets a "from page N" field + "Place from page N". - tests reworked: sparse numbers + gaps, place-from-start, set-page-number route. No migration; nothing destructive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,8 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
const series = ref(null)
|
||||
const pages = ref([]) // flat, ordered: [{image_id, page_number, stated_page, thumbnail_url, image_url, source_post}]
|
||||
const dividers = ref([]) // [{id, anchor_image_id, page_number, title, stated_part}]
|
||||
const pending = ref([]) // staged-from-post: [{post:{id,title}|null, pages:[{image_id, stated_page, thumbnail_url, image_url}]}]
|
||||
const gaps = ref([]) // missing-number gaps: [{after_image_id, before_image_id, from, to}]
|
||||
const pending = ref([]) // staged-from-post: [{post:{id,title}|null, start_page, pages:[{image_id, stated_page, thumbnail_url, image_url}]}]
|
||||
const partGaps = ref([]) // [{after_divider_id, start, end}]
|
||||
const pageCount = ref(0)
|
||||
const picker = ref([]) // gallery scroll results
|
||||
@@ -36,6 +37,7 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
series.value = body.series
|
||||
pages.value = body.pages || []
|
||||
dividers.value = body.dividers || []
|
||||
gaps.value = body.gaps || []
|
||||
pending.value = body.pending || []
|
||||
partGaps.value = body.part_gaps || []
|
||||
pageCount.value = pages.value.length
|
||||
@@ -57,14 +59,19 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
return partGaps.value.find(g => g.after_divider_id === dividerId) || null
|
||||
}
|
||||
|
||||
// ---- pages (series-wide) ----
|
||||
async function reorder(orderedImageIds) {
|
||||
await api.post(`/api/series/${tagId.value}/reorder`, {
|
||||
body: { image_ids: orderedImageIds }
|
||||
// ---- pages (number-driven) ----
|
||||
// Set one page's number (sparse, gaps allowed; pass null to unnumber).
|
||||
async function setPageNumber(imageId, pageNumber) {
|
||||
await api.post(`/api/series/${tagId.value}/pages/number`, {
|
||||
body: { image_id: imageId, page_number: pageNumber }
|
||||
})
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function gapAfter(imageId) {
|
||||
return gaps.value.find(g => g.after_image_id === imageId) || null
|
||||
}
|
||||
|
||||
async function remove(imageId) {
|
||||
await api.post(`/api/series/${tagId.value}/pages/remove`, {
|
||||
body: { image_ids: [imageId] }
|
||||
@@ -108,9 +115,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
}
|
||||
|
||||
// ---- pending staging (add-from-post) ----
|
||||
async function placePending(imageIds, beforeImageId = null) {
|
||||
// Place pending pages, numbered sequentially from startPage in the given order.
|
||||
async function placePending(imageIds, startPage = null) {
|
||||
await api.post(`/api/series/${tagId.value}/pending/place`, {
|
||||
body: { image_ids: imageIds, before_image_id: beforeImageId }
|
||||
body: { image_ids: imageIds, start_page: startPage }
|
||||
})
|
||||
await refresh()
|
||||
toast({ text: 'Pages placed into the series', type: 'success' })
|
||||
@@ -143,10 +151,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
tagId, series, pages, dividers, pending, partGaps, pageCount,
|
||||
tagId, series, pages, dividers, gaps, pending, partGaps, pageCount,
|
||||
picker, pickerCursor, pickerSelection, loading,
|
||||
load, refresh, dividerAt, partGapAfter,
|
||||
reorder, remove, setCover, placePending,
|
||||
load, refresh, dividerAt, partGapAfter, gapAfter,
|
||||
setPageNumber, remove, setCover, placePending,
|
||||
createDivider, renameDivider, setDividerPart, deleteDivider,
|
||||
loadPicker, togglePick, addSelected
|
||||
}
|
||||
|
||||
@@ -25,23 +25,22 @@
|
||||
</div>
|
||||
|
||||
<p class="fc-series__hint">
|
||||
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.
|
||||
One continuous series. <strong>Each page's number is yours</strong> — type
|
||||
it on the card; gaps are fine. Drag a page onto a <strong>gap's edge</strong>
|
||||
to give it the adjacent number, or onto the end to append. New pages from a
|
||||
post land in the <strong>pending tray</strong> to sort.
|
||||
</p>
|
||||
|
||||
<!-- Pending tray: pages staged from a post, grouped by source post, for the
|
||||
operator to drop junk and place into the run. -->
|
||||
<!-- Pending tray: pages staged from a post, grouped by source post. Drop the
|
||||
junk, set the start page, then place them numbered from there. -->
|
||||
<div v-if="store.pending.length" class="fc-pending">
|
||||
<div class="fc-pending__head">
|
||||
<v-icon size="small">mdi-tray-arrow-down</v-icon>
|
||||
<span>Pending — sort into the series</span>
|
||||
</div>
|
||||
<p class="fc-pending__hint">
|
||||
New pages from a post land here. Drop the ones that don't belong
|
||||
(text-free alternates, bumpers), then place the rest into the run.
|
||||
Drop the pages that don't belong (text-free alternates, bumpers), then
|
||||
place the rest — they'll be numbered up from the start page you set.
|
||||
</p>
|
||||
<section v-for="(grp, gi) in store.pending" :key="gi" class="fc-pgroup">
|
||||
<header class="fc-pgroup__head">
|
||||
@@ -50,22 +49,26 @@
|
||||
{{ grp.post?.title || 'Unknown post' }}
|
||||
</span>
|
||||
<span class="fc-pgroup__count">{{ grp.pages.length }} pending</span>
|
||||
<label class="fc-pgroup__startfield">
|
||||
from page
|
||||
<input
|
||||
class="fc-pgroup__start" type="number" min="1" placeholder="?"
|
||||
:value="startDraft[gi] ?? grp.start_page ?? ''"
|
||||
@input="startDraft[gi] = $event.target.value"
|
||||
>
|
||||
</label>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
size="small" variant="tonal" color="accent"
|
||||
prepend-icon="mdi-playlist-plus"
|
||||
@click="store.placePending(grp.pages.map(p => p.image_id))"
|
||||
>Place all into series</v-btn>
|
||||
:disabled="effectiveStart(grp, gi) == null"
|
||||
@click="placeGroup(grp, gi)"
|
||||
>Place from page {{ effectiveStart(grp, gi) ?? '?' }}</v-btn>
|
||||
</header>
|
||||
<div class="fc-pgroup__pages">
|
||||
<div v-for="p in grp.pages" :key="p.image_id" class="fc-ppage">
|
||||
<img :src="p.thumbnail_url" alt="" loading="lazy" />
|
||||
<div class="fc-ppage__actions">
|
||||
<v-btn
|
||||
size="x-small" variant="flat" icon="mdi-playlist-plus"
|
||||
title="Place this page into the series"
|
||||
@click="store.placePending([p.image_id])"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small" variant="flat" icon="mdi-close"
|
||||
title="Drop — doesn't belong in the series"
|
||||
@@ -77,66 +80,58 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- One continuous page run; chapter dividers render inline before their
|
||||
anchor page. -->
|
||||
<!-- One continuous run, ordered by each page's number. Chapter dividers and
|
||||
missing-number gap blocks render inline. -->
|
||||
<div v-if="store.pages.length" class="fc-run">
|
||||
<template v-for="(p, pi) in store.pages" :key="p.image_id">
|
||||
<template v-for="p 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-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
|
||||
:model-value="titleDraft[store.dividerAt(p.image_id).id]"
|
||||
placeholder="Untitled chapter"
|
||||
density="compact" variant="plain" hide-details
|
||||
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))"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
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="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>
|
||||
</template>
|
||||
<div v-if="store.dividerAt(p.image_id)" 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-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
|
||||
:model-value="titleDraft[store.dividerAt(p.image_id).id]"
|
||||
placeholder="Untitled chapter"
|
||||
density="compact" variant="plain" hide-details
|
||||
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))"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
size="x-small" variant="text" icon="mdi-close"
|
||||
title="Remove this chapter divider"
|
||||
@click="store.deleteDivider(store.dividerAt(p.image_id).id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- the page -->
|
||||
<div
|
||||
class="fc-page" draggable="true"
|
||||
:class="{ 'fc-page--dragging': drag === pi }"
|
||||
@dragstart="drag = pi"
|
||||
:class="{ 'fc-page--dragging': drag === p.image_id }"
|
||||
@dragstart="drag = p.image_id"
|
||||
@dragend="drag = null"
|
||||
@dragover.prevent
|
||||
@drop="onDrop(pi)"
|
||||
>
|
||||
<span class="fc-page__pn">{{ p.page_number }}</span>
|
||||
<label class="fc-page__numfield" title="Page number (gaps allowed)">
|
||||
<input
|
||||
class="fc-page__num" type="number" min="1"
|
||||
:value="numDraft[p.image_id] ?? ''" placeholder="—"
|
||||
@input="numDraft[p.image_id] = $event.target.value"
|
||||
@keydown.enter.prevent="commitNum(p)"
|
||||
@blur="commitNum(p)"
|
||||
@mousedown.stop
|
||||
>
|
||||
</label>
|
||||
<img :src="p.thumbnail_url" alt="" loading="lazy" />
|
||||
<span
|
||||
v-if="p.source_post?.title" class="fc-page__src"
|
||||
@@ -161,7 +156,29 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- gap block after this page (drop a page on an edge to number it) -->
|
||||
<div
|
||||
v-if="store.gapAfter(p.image_id)" class="fc-gapblock"
|
||||
@dragover.prevent
|
||||
>
|
||||
<div
|
||||
class="fc-gapblock__edge"
|
||||
@drop="onGapDrop(store.gapAfter(p.image_id).from)"
|
||||
>← {{ store.gapAfter(p.image_id).from }}</div>
|
||||
<span class="fc-gapblock__label">{{ gapLabel(store.gapAfter(p.image_id)) }}</span>
|
||||
<div
|
||||
class="fc-gapblock__edge"
|
||||
@drop="onGapDrop(store.gapAfter(p.image_id).to)"
|
||||
>{{ store.gapAfter(p.image_id).to }} →</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- append zone: drop here to give the page the next number -->
|
||||
<div class="fc-append" @dragover.prevent @drop="onAppendDrop">
|
||||
<v-icon size="small">mdi-tray-plus</v-icon>
|
||||
drop here to append (page {{ nextNumber }})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="fc-series__empty">
|
||||
@@ -217,30 +234,37 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
|
||||
import { useSeriesManageStore } from '../stores/seriesManage.js'
|
||||
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||
import TagRenameDialog from '../components/modal/TagRenameDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useSeriesManageStore()
|
||||
const sentinel = ref(null)
|
||||
const drag = ref(null) // index in the flat pages run being dragged
|
||||
const drag = ref(null) // image_id of the page being dragged
|
||||
const numDraft = reactive({}) // image_id -> draft page number (string)
|
||||
const titleDraft = reactive({}) // dividerId -> draft title
|
||||
const partDraft = reactive({}) // dividerId -> draft stated_part (string)
|
||||
const startDraft = reactive({}) // pending group index -> draft start page (string)
|
||||
const pickerOpen = ref(false)
|
||||
const renameOpen = ref(false)
|
||||
|
||||
// A series IS a Tag(kind=series); TagRenameDialog PATCHes /api/tags/<id> and
|
||||
// handles the same-name collision→merge flow. Reflect the new name in place.
|
||||
function onRenamed(updated) {
|
||||
renameOpen.value = false
|
||||
if (store.series && updated?.name) store.series.name = updated.name
|
||||
}
|
||||
|
||||
// Keep local divider drafts in sync with loaded dividers; edits commit on
|
||||
// blur/Enter, which refreshes and resets the draft to the saved value.
|
||||
// Keep card/divider drafts in sync with loaded data; edits commit on blur/Enter,
|
||||
// which refreshes and resets the draft to the saved value.
|
||||
watch(() => store.pages, (ps) => {
|
||||
for (const k of Object.keys(numDraft)) delete numDraft[k]
|
||||
for (const p of ps) {
|
||||
numDraft[p.image_id] = p.page_number == null ? '' : String(p.page_number)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => store.dividers, (divs) => {
|
||||
for (const k of Object.keys(titleDraft)) delete titleDraft[k]
|
||||
for (const k of Object.keys(partDraft)) delete partDraft[k]
|
||||
@@ -250,6 +274,46 @@ watch(() => store.dividers, (divs) => {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Next number = one past the highest assigned page number (1 if none).
|
||||
const nextNumber = computed(() => {
|
||||
let max = 0
|
||||
for (const p of store.pages) {
|
||||
if (p.page_number != null && p.page_number > max) max = p.page_number
|
||||
}
|
||||
return max + 1
|
||||
})
|
||||
|
||||
function commitNum(p) {
|
||||
const raw = (numDraft[p.image_id] ?? '').trim()
|
||||
const next = raw === '' ? null : parseInt(raw, 10)
|
||||
if (raw !== '' && (Number.isNaN(next) || next < 1)) {
|
||||
numDraft[p.image_id] = p.page_number == null ? '' : String(p.page_number)
|
||||
return
|
||||
}
|
||||
if (next === (p.page_number ?? null)) return
|
||||
store.setPageNumber(p.image_id, next)
|
||||
}
|
||||
|
||||
function onGapDrop(number) {
|
||||
if (drag.value == null) return
|
||||
const id = drag.value
|
||||
drag.value = null
|
||||
store.setPageNumber(id, number)
|
||||
}
|
||||
|
||||
function onAppendDrop() {
|
||||
if (drag.value == null) return
|
||||
const id = drag.value
|
||||
drag.value = null
|
||||
store.setPageNumber(id, nextNumber.value)
|
||||
}
|
||||
|
||||
function gapLabel(g) {
|
||||
return g.from === g.to
|
||||
? `page ${g.from} missing`
|
||||
: `pages ${g.from}–${g.to} missing`
|
||||
}
|
||||
|
||||
function commitTitle(d) {
|
||||
const v = (titleDraft[d.id] || '').trim()
|
||||
if (v === (d.title || '')) return
|
||||
@@ -267,19 +331,21 @@ function commitPart(d) {
|
||||
store.setDividerPart(d.id, next)
|
||||
}
|
||||
|
||||
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 }
|
||||
// Pending: effective start page = the typed override, else the post's parsed
|
||||
// start. null when neither is set (operator must type one).
|
||||
function effectiveStart(grp, gi) {
|
||||
const raw = (startDraft[gi] ?? '').toString().trim()
|
||||
if (raw !== '') {
|
||||
const n = parseInt(raw, 10)
|
||||
return Number.isNaN(n) || n < 1 ? null : n
|
||||
}
|
||||
return grp.start_page ?? null
|
||||
}
|
||||
|
||||
function onDrop(toIdx) {
|
||||
if (drag.value === null || drag.value === toIdx) { drag.value = null; return }
|
||||
const ordered = moveItem(
|
||||
store.pages.map(p => p.image_id), drag.value, toIdx
|
||||
)
|
||||
drag.value = null
|
||||
store.reorder(ordered)
|
||||
function placeGroup(grp, gi) {
|
||||
const start = effectiveStart(grp, gi)
|
||||
if (start == null) return
|
||||
store.placePending(grp.pages.map(p => p.image_id), start)
|
||||
}
|
||||
|
||||
useInfiniteScroll(sentinel, () => store.loadPicker())
|
||||
@@ -303,7 +369,7 @@ onMounted(async () => {
|
||||
margin-bottom: 18px; max-width: 820px;
|
||||
}
|
||||
|
||||
/* One continuous page run with inline divider bars. */
|
||||
/* One continuous run with inline dividers + gap blocks. */
|
||||
.fc-run {
|
||||
display: grid; gap: 10px; max-width: 1100px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
@@ -339,7 +405,38 @@ onMounted(async () => {
|
||||
}
|
||||
.fc-divider__title { flex: 1 1 200px; min-width: 120px; font-size: 15px; }
|
||||
|
||||
/* Big page tiles */
|
||||
/* Gap block — one per gap; drop a page on an edge to number it. */
|
||||
.fc-gapblock {
|
||||
grid-column: 1 / -1;
|
||||
display: flex; align-items: stretch; gap: 8px;
|
||||
padding: 4px; border: 1px dashed rgb(var(--v-theme-on-surface-variant), 0.5);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.fc-gapblock__edge {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-width: 80px; padding: 6px 10px; border-radius: 6px;
|
||||
font-variant-numeric: tabular-nums; font-size: 13px;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
color: rgb(var(--v-theme-on-surface-variant)); cursor: copy;
|
||||
}
|
||||
.fc-gapblock__edge:hover {
|
||||
background: rgb(var(--v-theme-accent), 0.2);
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-gapblock__label {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
|
||||
.fc-append {
|
||||
grid-column: 1 / -1;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
padding: 12px; border: 1px dashed rgb(var(--v-theme-on-surface-variant), 0.4);
|
||||
border-radius: 8px; font-size: 13px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
|
||||
/* Page tiles */
|
||||
.fc-page {
|
||||
position: relative; border-radius: 8px; overflow: hidden; cursor: grab;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
@@ -350,13 +447,19 @@ onMounted(async () => {
|
||||
width: 100%; aspect-ratio: 3 / 4; object-fit: contain;
|
||||
display: block; background: rgb(var(--v-theme-background));
|
||||
}
|
||||
.fc-page__pn {
|
||||
.fc-page__numfield {
|
||||
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));
|
||||
background: rgb(var(--v-theme-accent)); border-radius: 12px;
|
||||
padding: 0 4px; height: 24px; display: flex; align-items: center;
|
||||
}
|
||||
.fc-page__num {
|
||||
width: 34px; height: 22px; text-align: center;
|
||||
font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums;
|
||||
background: transparent; border: none;
|
||||
color: rgb(var(--v-theme-on-accent, 0 0 0));
|
||||
}
|
||||
.fc-page__num:focus { outline: none; }
|
||||
.fc-page__num::placeholder { color: rgb(var(--v-theme-on-accent, 0 0 0)); opacity: 0.5; }
|
||||
.fc-page__src {
|
||||
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
|
||||
display: flex; align-items: center; gap: 4px; padding: 3px 6px;
|
||||
@@ -372,11 +475,6 @@ onMounted(async () => {
|
||||
background: rgba(0, 0, 0, 0.55); color: #fff;
|
||||
}
|
||||
|
||||
.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-series__empty {
|
||||
padding: 40px; text-align: center; color: rgb(var(--v-theme-on-surface-variant));
|
||||
max-width: 1100px;
|
||||
@@ -401,15 +499,27 @@ onMounted(async () => {
|
||||
.fc-pgroup { margin-bottom: 12px; }
|
||||
.fc-pgroup__head {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fc-pgroup__title {
|
||||
display: inline-flex; align-items: center; gap: 4px; max-width: 360px;
|
||||
display: inline-flex; align-items: center; gap: 4px; max-width: 320px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-size: 13px; font-weight: 600;
|
||||
}
|
||||
.fc-pgroup__count {
|
||||
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-pgroup__startfield {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-pgroup__start {
|
||||
width: 56px; text-align: center; font-size: 13px;
|
||||
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-pgroup__start:focus { outline: none; border-color: rgb(var(--v-theme-accent)); }
|
||||
.fc-pgroup__pages {
|
||||
display: grid; gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user