feat(books): add jump rail display preference

Let readers disable jump rails from grid display controls and skip bucket queries while the preference is off.
This commit is contained in:
neon
2026-07-16 04:30:22 +00:00
parent 504c9ce0e7
commit 095b347aff
21 changed files with 176 additions and 7 deletions
+13
View File
@@ -21,6 +21,8 @@ withDefaults(
total: number
coverSize: number
gridGap: number
showJumpRailToggle?: boolean
showJumpRails?: boolean
viewMode: BookViewMode
selectionMode?: boolean
showSelection?: boolean
@@ -56,6 +58,7 @@ withDefaults(
const emit = defineEmits<{
'update:coverSize': [value: number]
'update:gridGap': [value: number]
'update:showJumpRails': [value: boolean]
'update:viewMode': [value: BookViewMode]
'toggle-selection': []
'update:coverShape': [value: 'square' | 'circle']
@@ -66,6 +69,10 @@ const { t } = useI18n()
const mobileDisplayOpen = ref(false)
const mobileSearchOpen = ref(false)
function handleShowJumpRailsUpdate(value: boolean) {
emit('update:showJumpRails', value)
}
</script>
<template>
@@ -149,6 +156,8 @@ const mobileSearchOpen = ref(false)
:view-mode="viewMode"
:cover-size="coverSize"
:grid-gap="gridGap"
:show-jump-rail-toggle="showJumpRailToggle"
:show-jump-rails="showJumpRails"
:cover-shape="coverShape"
:cover-size-min="coverSizeMin"
:cover-size-max="coverSizeMax"
@@ -158,6 +167,7 @@ const mobileSearchOpen = ref(false)
:grid-gap-step="gridGapStep"
@update:cover-size="emit('update:coverSize', $event)"
@update:grid-gap="emit('update:gridGap', $event)"
@update:show-jump-rails="handleShowJumpRailsUpdate"
@update:cover-shape="emit('update:coverShape', $event)"
>
<template #columns>
@@ -201,6 +211,8 @@ const mobileSearchOpen = ref(false)
:view-mode="viewMode"
:cover-size="coverSize"
:grid-gap="gridGap"
:show-jump-rail-toggle="showJumpRailToggle"
:show-jump-rails="showJumpRails"
:cover-shape="coverShape"
:cover-size-min="coverSizeMin"
:cover-size-max="coverSizeMax"
@@ -210,6 +222,7 @@ const mobileSearchOpen = ref(false)
:grid-gap-step="gridGapStep"
@update:cover-size="emit('update:coverSize', $event)"
@update:grid-gap="emit('update:gridGap', $event)"
@update:show-jump-rails="handleShowJumpRailsUpdate"
@update:cover-shape="emit('update:coverShape', $event)"
>
<template #columns>
@@ -0,0 +1,34 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import ViewHeaderDisplayControls from './ViewHeaderDisplayControls.vue'
function mountControls(overrides: Partial<InstanceType<typeof ViewHeaderDisplayControls>['$props']> = {}) {
return mount(ViewHeaderDisplayControls, {
props: {
viewMode: 'grid',
coverSize: 120,
gridGap: 24,
...overrides,
},
})
}
describe('ViewHeaderDisplayControls', () => {
it('renders the jump rail checkbox in grid mode and emits changes', async () => {
const wrapper = mountControls({ showJumpRailToggle: true, showJumpRails: true })
const checkbox = wrapper.get('input[type="checkbox"]')
expect(wrapper.text()).toContain('Show jump rails')
expect((checkbox.element as HTMLInputElement).checked).toBe(true)
await checkbox.setValue(false)
expect(wrapper.emitted('update:showJumpRails')).toEqual([[false]])
})
it('omits the jump rail checkbox when unsupported or outside grid mode', () => {
expect(mountControls().find('input[type="checkbox"]').exists()).toBe(false)
expect(mountControls({ viewMode: 'list', showJumpRailToggle: true, showJumpRails: true }).find('input[type="checkbox"]').exists()).toBe(false)
expect(mountControls({ viewMode: 'table', showJumpRailToggle: true, showJumpRails: true }).find('input[type="checkbox"]').exists()).toBe(false)
})
})
@@ -10,6 +10,8 @@ withDefaults(
viewMode: BookViewMode
coverSize: number
gridGap: number
showJumpRailToggle?: boolean
showJumpRails?: boolean
coverShape?: 'square' | 'circle'
coverSizeMin?: number
coverSizeMax?: number
@@ -31,8 +33,13 @@ withDefaults(
const emit = defineEmits<{
'update:coverSize': [value: number]
'update:gridGap': [value: number]
'update:showJumpRails': [value: boolean]
'update:coverShape': [value: 'square' | 'circle']
}>()
function handleShowJumpRailsChange(event: Event) {
emit('update:showJumpRails', (event.target as HTMLInputElement).checked)
}
</script>
<template>
@@ -71,6 +78,16 @@ const emit = defineEmits<{
class="w-full accent-primary cursor-pointer"
/>
</div>
<label v-if="showJumpRailToggle && viewMode === 'grid'" class="flex cursor-pointer items-center justify-between gap-3 pt-1">
<span class="text-xs text-muted-foreground">{{ t('components.viewHeader.displayControls.showJumpRails') }}</span>
<input
type="checkbox"
:checked="showJumpRails"
class="size-4 shrink-0 cursor-pointer rounded border-input accent-primary"
@change="handleShowJumpRailsChange"
/>
</label>
</template>
<template v-else>
@@ -12,6 +12,7 @@ function resetDisplaySettings() {
settings.squareGridGap.value = 28
settings.viewMode.value = 'grid'
settings.cardOverlays.value = ['progress-bar', 'format', 'rating', 'read-status', 'series-position']
settings.showJumpRails.value = true
settings.smartScopeFilterExpanded.value = true
settings.authorCoverSize.value = 120
settings.authorCoverShape.value = 'circle'
@@ -44,6 +45,7 @@ describe('useDisplaySettings preferences helpers', () => {
coverSizeScope: 'per-view',
viewMode: 'grid',
cardOverlays: ['format', 'rating'],
showJumpRails: true,
bookSpineOverlay: 'strong',
bookCoverDisplayMode: 'natural-bottom',
thumbnailClickAction: 'reader',
@@ -56,6 +58,7 @@ describe('useDisplaySettings preferences helpers', () => {
squareCoverSize: 'large',
coverSizeScope: 'synced',
cardOverlays: ['format', 'unknown', 'format', 'lock-status'],
showJumpRails: false,
bookCoverDisplayMode: 'fill-crop',
thumbnailClickAction: 'details',
tableDensity: 'huge',
@@ -66,6 +69,7 @@ describe('useDisplaySettings preferences helpers', () => {
portraitCoverSize: 400,
coverSizeScope: 'synced',
cardOverlays: ['format', 'lock-status'],
showJumpRails: false,
bookCoverDisplayMode: 'fill-crop',
thumbnailClickAction: 'details',
})
@@ -77,6 +81,7 @@ describe('useDisplaySettings preferences helpers', () => {
gridGap: 120,
viewMode: 'table',
authorCoverShape: 'square',
showJumpRails: false,
tableZebraStriping: true,
bookShadowStrength: 'strong',
bookCoverDisplayMode: 'natural-bottom',
@@ -88,6 +93,7 @@ describe('useDisplaySettings preferences helpers', () => {
expect(settings.gridGap.value).toBe(80)
expect(settings.viewMode.value).toBe('table')
expect(settings.authorCoverShape.value).toBe('square')
expect(settings.showJumpRails.value).toBe(false)
expect(settings.tableZebraStriping.value).toBe(true)
expect(settings.tableDensity.value).toBe('comfortable')
expect(settings.bookShadowStrength.value).toBe('strong')
@@ -100,6 +106,12 @@ describe('useDisplaySettings preferences helpers', () => {
expect(sanitizeDisplayPreferences('bad')).toEqual({})
})
it('sanitizes only boolean showJumpRails values', () => {
expect(sanitizeDisplayPreferences({ showJumpRails: true })).toEqual({ showJumpRails: true })
expect(sanitizeDisplayPreferences({ showJumpRails: false })).toEqual({ showJumpRails: false })
expect(sanitizeDisplayPreferences({ showJumpRails: 'false' })).toEqual({})
})
it('includes seriesCardCoverMode in snapshot', () => {
settings.seriesCardCoverMode.value = 'first-volume'
const snap = getDisplayPreferencesSnapshot()
@@ -12,6 +12,7 @@ function validDisplayPreferences(overrides: Partial<DisplayPreferences> = {}): D
squareGridGap: 20,
viewMode: 'grid',
cardOverlays: ['progress-bar', 'format', 'rating'],
showJumpRails: true,
smartScopeFilterExpanded: true,
authorCoverSize: 140,
authorCoverShape: 'circle',
@@ -193,6 +194,24 @@ describe('useDisplaySettingsSync', () => {
)
})
it('syncs showJumpRails changes to the server', async () => {
vi.useFakeTimers()
const { apiMock, displaySettings, sync } = await loadModules()
apiMock.mockResolvedValue({ ok: true })
sync.initDisplaySettingsSync()
displaySettings.useDisplaySettings().showJumpRails.value = false
await vi.advanceTimersByTimeAsync(1500)
expect(apiMock).toHaveBeenCalledWith(
'/api/v1/user-preferences/display',
expect.objectContaining({
method: 'PUT',
body: expect.stringContaining('"showJumpRails":false'),
}),
)
})
it('flushes a pending save with keepalive on pagehide', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn<() => Promise<Response>>().mockResolvedValue({ ok: true } as Response)
@@ -140,6 +140,7 @@ function normalizeViewMode(v: unknown): BookViewMode {
const viewMode = ref<BookViewMode>(normalizeViewMode(storage.get('viewMode', 'grid')))
const cardOverlays = ref<CardOverlayKey[]>(normalizeCardOverlays(storage.get('cardOverlays', DEFAULT_CARD_OVERLAYS)))
const showJumpRails = ref(storage.get<boolean>('showJumpRails', true) !== false)
const smartScopeFilterExpanded = ref(storage.get<boolean>('smartScopeFilterExpanded', true) === true)
const authorCoverSize = ref(normalizePositiveInteger(storage.get('authorCoverSize', 120), 120, 100, 400))
const authorCoverShape = ref<AuthorCoverShape>(normalizeAuthorCoverShape(storage.get('authorCoverShape', 'circle')))
@@ -167,6 +168,7 @@ watch(portraitGridGap, (v) => storage.set('portraitGridGap', v))
watch(squareGridGap, (v) => storage.set('squareGridGap', v))
watch(viewMode, (v) => storage.set('viewMode', v))
watch(cardOverlays, (v) => storage.set('cardOverlays', normalizeCardOverlays(v)), { deep: true })
watch(showJumpRails, (v) => storage.set('showJumpRails', v))
watch(smartScopeFilterExpanded, (v) => storage.set('smartScopeFilterExpanded', v))
watch(authorCoverSize, (v) => storage.set('authorCoverSize', v))
watch(authorCoverShape, (v) => storage.set('authorCoverShape', normalizeAuthorCoverShape(v)))
@@ -192,6 +194,7 @@ export function getDisplayPreferencesSnapshot(): DisplayPreferences {
squareGridGap: normalizePositiveInteger(squareGridGap.value, DEFAULT_GRID_GAP, 1, 80),
viewMode: normalizeViewMode(viewMode.value),
cardOverlays: normalizeCardOverlays(cardOverlays.value),
showJumpRails: showJumpRails.value !== false,
smartScopeFilterExpanded: smartScopeFilterExpanded.value === true,
authorCoverSize: normalizePositiveInteger(authorCoverSize.value, 120, 100, 400),
authorCoverShape: normalizeAuthorCoverShape(authorCoverShape.value),
@@ -224,6 +227,7 @@ export function sanitizeDisplayPreferences(raw: unknown): Partial<DisplayPrefere
if (typeof obj.squareGridGap === 'number') out.squareGridGap = normalizePositiveInteger(obj.squareGridGap, DEFAULT_GRID_GAP, 1, 80)
if (BOOK_VIEW_MODES.includes(obj.viewMode as BookViewMode)) out.viewMode = obj.viewMode as BookViewMode
if (Array.isArray(obj.cardOverlays)) out.cardOverlays = normalizeCardOverlays(obj.cardOverlays)
if (typeof obj.showJumpRails === 'boolean') out.showJumpRails = obj.showJumpRails
if (typeof obj.smartScopeFilterExpanded === 'boolean') out.smartScopeFilterExpanded = obj.smartScopeFilterExpanded
if (typeof obj.authorCoverSize === 'number') out.authorCoverSize = normalizePositiveInteger(obj.authorCoverSize, 120, 100, 400)
if (AUTHOR_COVER_SHAPES.includes(obj.authorCoverShape as AuthorCoverShape)) out.authorCoverShape = obj.authorCoverShape as AuthorCoverShape
@@ -265,6 +269,7 @@ export function applyDisplayPreferences(raw: unknown): void {
if (prefs.squareGridGap !== undefined) squareGridGap.value = prefs.squareGridGap
if (prefs.viewMode !== undefined) viewMode.value = prefs.viewMode
if (prefs.cardOverlays !== undefined) cardOverlays.value = prefs.cardOverlays
if (prefs.showJumpRails !== undefined) showJumpRails.value = prefs.showJumpRails
if (prefs.smartScopeFilterExpanded !== undefined) smartScopeFilterExpanded.value = prefs.smartScopeFilterExpanded
if (prefs.authorCoverSize !== undefined) authorCoverSize.value = prefs.authorCoverSize
if (prefs.authorCoverShape !== undefined) authorCoverShape.value = prefs.authorCoverShape
@@ -291,6 +296,7 @@ export function useDisplaySettings() {
squareGridGap,
viewMode,
cardOverlays,
showJumpRails,
smartScopeFilterExpanded,
authorCoverSize,
authorCoverShape,
@@ -109,6 +109,7 @@ export function initDisplaySettingsSync(): void {
settings.squareGridGap.value,
settings.viewMode.value,
settings.cardOverlays.value.join('\u0000'),
settings.showJumpRails.value,
settings.smartScopeFilterExpanded.value,
settings.authorCoverSize.value,
settings.authorCoverShape.value,
@@ -86,9 +86,10 @@ async function flush() {
await nextTick()
}
function setup(options: { scope?: number | null; viewMode?: string; collapse?: boolean; q?: string } = {}) {
function setup(options: { scope?: number | null; viewMode?: string; collapse?: boolean; q?: string; railEnabled?: boolean } = {}) {
const scopeId = ref<number | null>(options.scope === undefined ? 1 : options.scope)
const viewMode = ref(options.viewMode ?? 'table')
const railEnabled = ref(options.railEnabled ?? true)
const collapseEnabled = ref(options.collapse ?? false)
const q = ref(options.q ?? '')
const win = useBookViewWindow({
@@ -96,10 +97,11 @@ function setup(options: { scope?: number | null; viewMode?: string; collapse?: b
listEndpoint: (id) => `/api/v1/libraries/${id}/books`,
bucketsEndpoint: (id) => `/api/v1/libraries/${id}/books/jump-buckets`,
viewMode,
railEnabled,
collapseEnabled,
q,
})
return { win, scopeId, viewMode, collapseEnabled, q }
return { win, scopeId, viewMode, railEnabled, collapseEnabled, q }
}
function listRequests(): number[] {
@@ -108,6 +110,10 @@ function listRequests(): number[] {
.map(([, init]) => (JSON.parse(String(init?.body)) as { pagination: { page: number } }).pagination.page)
}
function jumpRequestCount(): number {
return fetchMock.mock.calls.filter(([url]) => url.includes('jump-buckets')).length
}
describe('useBookViewWindow', () => {
beforeEach(() => {
fetchMock.mockReset()
@@ -248,6 +254,22 @@ describe('useBookViewWindow', () => {
expect(win.railGutterReserved.value).toBe(false)
})
it('does not request buckets while jump rails are disabled and loads them when enabled', async () => {
mockApi(250, [bucket('A', 0), bucket('B', 120)])
const { win, railEnabled } = setup({ viewMode: 'grid', railEnabled: false })
await flush()
expect(jumpRequestCount()).toBe(0)
expect(win.railVisible.value).toBe(false)
railEnabled.value = true
await flush()
await flush()
expect(jumpRequestCount()).toBe(1)
expect(win.railVisible.value).toBe(true)
})
it('shows the jump rail in grid mode once buckets load and jumps scroll to the bucket index', async () => {
mockApi(250, [bucket('A', 0), bucket('B', 120)])
const { win } = setup({ viewMode: 'grid' })
@@ -28,6 +28,7 @@ export function useBookViewWindow(options: {
listEndpoint: (id: number) => string
bucketsEndpoint: (id: number) => string
viewMode: Ref<string>
railEnabled?: Ref<boolean>
railViewport?: Ref<HTMLElement | null>
collapseEnabled?: Ref<boolean>
q?: Ref<string>
@@ -68,7 +69,9 @@ export function useBookViewWindow(options: {
const bucketKind = computed(() => jumpBucketKindForSort(sort.value))
const primarySortField = computed(() => sort.value[0]?.field ?? 'title')
const railModeActive = computed(() => options.viewMode.value === 'grid')
const railEligible = computed(() => bucketKind.value !== null && railModeActive.value && window.total.value >= MIN_TOTAL_FOR_RAIL)
const railEligible = computed(
() => (options.railEnabled?.value ?? true) && bucketKind.value !== null && railModeActive.value && window.total.value >= MIN_TOTAL_FOR_RAIL,
)
const bucketsApi = useJumpBuckets({
endpoint: bucketsEndpoint,
+1
View File
@@ -4129,6 +4129,7 @@
"display": "Anzeige",
"coverSize": "Covergröße",
"gridGap": "Rasterabstand",
"showJumpRails": "Show jump rails",
"columnsHint": "Verwende das Spaltensichtbarkeits-Panel in der Tabellenkopfzeile, um Spalten ein- oder auszublenden.",
"coverShape": "Coverform",
"circle": "Kreis",
+1
View File
@@ -4087,6 +4087,7 @@
"display": "Display",
"coverSize": "Cover size",
"gridGap": "Grid gap",
"showJumpRails": "Show jump rails",
"columnsHint": "Use the column visibility panel in the table header to show or hide columns.",
"coverShape": "Cover shape",
"circle": "Circle",
+1
View File
@@ -4129,6 +4129,7 @@
"display": "Weergave",
"coverSize": "Omslaggrootte",
"gridGap": "Rasterafstand",
"showJumpRails": "Show jump rails",
"columnsHint": "Gebruik het paneel voor kolomzichtbaarheid in de tabelkop om kolommen te tonen of te verbergen.",
"coverShape": "Omslagvorm",
"circle": "Cirkel",
+1
View File
@@ -4129,6 +4129,7 @@
"display": "Exibição",
"coverSize": "Tamanho da capa",
"gridGap": "Espaçamento da grade",
"showJumpRails": "Show jump rails",
"columnsHint": "Use o painel de visibilidade de colunas no cabeçalho da tabela para mostrar ou ocultar colunas.",
"coverShape": "Formato da capa",
"circle": "Círculo",
+1
View File
@@ -4129,6 +4129,7 @@
"display": "Prikaz",
"coverSize": "Velikost naslovnice",
"gridGap": "Razmik mreže",
"showJumpRails": "Show jump rails",
"columnsHint": "Uporabi ploščo za vidnost stolpcev v glavi tabele, da prikažeš ali skriješ stolpce.",
"coverShape": "Oblika naslovnice",
"circle": "Krog",
+4 -1
View File
@@ -50,7 +50,7 @@ const { viewMode, effectiveViewMode } = useEffectiveViewMode()
const { hasPermission, isDemoRestrictedAccount } = usePermissions()
const collectionId = shallowRef(Number(route.params.id))
const { tableDensity } = useDisplaySettings()
const { tableDensity, showJumpRails } = useDisplaySettings()
const { allSavedViews, saveView, renameView, deleteView, duplicateView, toggleFavorite, importViews } = useSavedViews('collection', collectionId)
const coverAspectRatio = computed(() => DEFAULT_COVER_ASPECT_RATIO)
const { coverSize, gridGap } = useViewDisplaySettings('collection', collectionId, coverAspectRatio)
@@ -109,6 +109,7 @@ const {
listEndpoint: (id) => `/api/v1/collections/${id}/books/query`,
bucketsEndpoint: (id) => `/api/v1/collections/${id}/books/jump-buckets`,
viewMode: effectiveViewMode,
railEnabled: showJumpRails,
railViewport: mainRef,
collapseEnabled: collapseEnabledRef,
q: debouncedQuery,
@@ -438,6 +439,8 @@ defineOptions({ name: 'CollectionView' })
:total="total"
v-model:coverSize="coverSize"
v-model:gridGap="gridGap"
:show-jump-rail-toggle="true"
v-model:showJumpRails="showJumpRails"
v-model:viewMode="viewMode"
:selection-mode="selectionMode"
:searchable="true"
+4 -1
View File
@@ -78,6 +78,7 @@ const libraryId = shallowRef<number | null>(route.params.id ? Number(route.param
const currentLibrary = computed(() => libraries.value.find((l) => l.id === libraryId.value))
const currentCoverAspectRatio = computed(() => currentLibrary.value?.coverAspectRatio ?? DEFAULT_COVER_ASPECT_RATIO)
const { coverSize, gridGap } = useViewDisplaySettings('library', libraryId, currentCoverAspectRatio)
const { tableDensity, showJumpRails } = useDisplaySettings()
const libraryNotFound = computed(() => librariesLoaded.value && libraryId.value !== null && !currentLibrary.value)
const title = computed(() => currentLibrary.value?.name ?? t('views.library.title'))
@@ -138,6 +139,7 @@ const {
listEndpoint: (id) => `/api/v1/libraries/${id}/books`,
bucketsEndpoint: (id) => `/api/v1/libraries/${id}/books/jump-buckets`,
viewMode: effectiveViewMode,
railEnabled: showJumpRails,
railViewport: mainRef,
collapseEnabled: collapseEnabledRef,
q: debouncedQuery,
@@ -157,7 +159,6 @@ const hasSavedFilter = computed(() => savedFilter.value !== undefined)
const isFilterSaved = computed(() => JSON.stringify(filter.value) === JSON.stringify(savedFilter.value))
const { sortModel, isDefaultSort, sortSummary, resetSort } = useViewSort(sort, 'library', libraryId)
const { tableDensity } = useDisplaySettings()
const { allSavedViews, saveView, renameView, deleteView, duplicateView, toggleFavorite, importViews } = useSavedViews('library', libraryId)
function handleSaveCurrentView(name: string) {
@@ -567,6 +568,8 @@ defineOptions({ name: 'HomeView' })
:total="total"
v-model:coverSize="coverSize"
v-model:gridGap="gridGap"
:show-jump-rail-toggle="true"
v-model:showJumpRails="showJumpRails"
v-model:viewMode="viewMode"
:selection-mode="selectionMode"
:searchable="true"
+4 -2
View File
@@ -65,12 +65,11 @@ const route = useRoute()
const router = useRouter()
const { viewMode, effectiveViewMode } = useEffectiveViewMode()
const { hasPermission, isDemoRestrictedAccount } = usePermissions()
const { smartScopeFilterExpanded } = useDisplaySettings()
const { smartScopeFilterExpanded, tableDensity, showJumpRails } = useDisplaySettings()
const smartScopeId = shallowRef(Number(route.params.id))
const coverAspectRatio = computed(() => DEFAULT_COVER_ASPECT_RATIO)
const { coverSize, gridGap } = useViewDisplaySettings('smartScope', smartScopeId, coverAspectRatio)
const { tableDensity } = useDisplaySettings()
const { allSavedViews, saveView, renameView, deleteView, duplicateView, toggleFavorite, importViews } = useSavedViews('smartScope', smartScopeId)
const { getEffectivePreference, setPreference, prefs } = useSeriesCollapsePreference()
@@ -118,6 +117,7 @@ const {
listEndpoint: (id) => `/api/v1/smart-scopes/${id}/books/query`,
bucketsEndpoint: (id) => `/api/v1/smart-scopes/${id}/books/jump-buckets`,
viewMode: effectiveViewMode,
railEnabled: showJumpRails,
railViewport: mainRef,
collapseEnabled: collapseEnabledRef,
q: debouncedQuery,
@@ -475,6 +475,8 @@ defineOptions({ name: 'SmartScopeView' })
:total="total"
v-model:coverSize="coverSize"
v-model:gridGap="gridGap"
:show-jump-rail-toggle="true"
v-model:showJumpRails="showJumpRails"
v-model:viewMode="viewMode"
:selection-mode="selectionMode"
:searchable="true"