feat(settings): add series number badge to book cover card overlay (#37)

Series position badge (#N) rendered in the top-right corner of cover
cards, sharing a flex-column container with the existing lock-status
badge. Shown only when seriesIndex is non-null. Whole numbers strip the
decimal (3.0 -> #3); fractional values display as-is (1.5 -> #1.5).
Hovering the badge shows a tooltip with the full series name and position.
Enabled by default in Appearance -> Card Overlays.

Closes #32
This commit is contained in:
neon
2026-05-18 13:48:38 -06:00
committed by GitHub
parent 5524cd9d0d
commit 4cb0149380
6 changed files with 169 additions and 10 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
import { ref, watch } from 'vue'
import { storage } from '@/services/storage'
export type CardOverlayKey = 'progress-bar' | 'format' | 'rating' | 'read-status' | 'lock-status'
export type CardOverlayKey = 'progress-bar' | 'format' | 'rating' | 'read-status' | 'lock-status' | 'series-position'
export type AuthorCoverShape = 'square' | 'circle'
export type CoverSizeScope = 'per-view' | 'synced'
export type TableDensity = 'compact' | 'comfortable' | 'roomy'
@@ -9,8 +9,8 @@ export type TableDensity = 'compact' | 'comfortable' | 'roomy'
const DEFAULT_PORTRAIT_COVER_SIZE = 130
const DEFAULT_SQUARE_COVER_SIZE = 150
const DEFAULT_GRID_GAP = 28
const CARD_OVERLAY_KEYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status', 'lock-status']
const DEFAULT_CARD_OVERLAYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status']
const CARD_OVERLAY_KEYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status', 'lock-status', 'series-position']
const DEFAULT_CARD_OVERLAYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status', 'series-position']
function normalizeCardOverlays(value: unknown): CardOverlayKey[] {
if (!Array.isArray(value)) return [...DEFAULT_CARD_OVERLAYS]
@@ -459,4 +459,63 @@ describe('BookCoverCard', () => {
expect(wrapper.find('[data-testid="send-dialog"]').exists()).toBe(false)
})
})
// ── series position overlay ───────────────────────────────────────────────
describe('series position overlay', () => {
it('hides badge when series-position is not in cardOverlays', () => {
mockCardOverlays.value = []
const wrapper = mountCard({ book: makeBook({ seriesIndex: 3, seriesName: 'Dune' }) })
expect(wrapper.text()).not.toContain('#3')
})
it('shows #3 badge when seriesIndex is 3 and overlay is enabled', () => {
mockCardOverlays.value = ['series-position']
const wrapper = mountCard({ book: makeBook({ seriesIndex: 3, seriesName: 'Dune' }) })
expect(wrapper.text()).toContain('#3')
})
it('shows #1.5 badge when seriesIndex is 1.5', () => {
mockCardOverlays.value = ['series-position']
const wrapper = mountCard({ book: makeBook({ seriesIndex: 1.5, seriesName: 'Dune' }) })
expect(wrapper.text()).toContain('#1.5')
})
it('strips trailing .0 for whole-number floats (3.0 shows as #3)', () => {
mockCardOverlays.value = ['series-position']
const wrapper = mountCard({ book: makeBook({ seriesIndex: 3.0, seriesName: 'Dune' }) })
expect(wrapper.text()).toContain('#3')
expect(wrapper.text()).not.toContain('#3.0')
})
it('hides badge when seriesIndex is null even if overlay is enabled', () => {
mockCardOverlays.value = ['series-position']
const wrapper = mountCard({ book: makeBook({ seriesIndex: null, seriesName: 'Dune' }) })
expect(wrapper.text()).not.toContain('#')
})
it('hides badge in selection mode', () => {
mockCardOverlays.value = ['series-position']
const wrapper = mountCard({ book: makeBook({ seriesIndex: 2, seriesName: 'Dune' }), selectionMode: true })
expect(wrapper.text()).not.toContain('#2')
})
it('hides badge for missing books', () => {
mockCardOverlays.value = ['series-position']
const wrapper = mountCard({ book: makeBook({ seriesIndex: 2, seriesName: 'Dune', status: 'missing' }) })
expect(wrapper.text()).not.toContain('#2')
})
it('tooltip content includes series name and number', () => {
mockCardOverlays.value = ['series-position']
const wrapper = mountCard({ book: makeBook({ seriesIndex: 3, seriesName: 'Sprawl' }) })
expect(wrapper.text()).toContain('Sprawl #3')
})
it('tooltip content shows only number when seriesName is null', () => {
mockCardOverlays.value = ['series-position']
const wrapper = mountCard({ book: makeBook({ seriesIndex: 3, seriesName: null }) })
expect(wrapper.text()).toContain('#3')
})
})
})
@@ -45,6 +45,7 @@ import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
import { useBookDownload } from '@/features/book/composables/useBookDownload'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
@@ -121,6 +122,20 @@ const showRatingOverlay = computed(() => cardOverlays.value.includes('rating') &
const showLockStatusPill = computed(() => cardOverlays.value.includes('lock-status') && !props.selectionMode && !isMissing.value)
const metadataLocked = computed(() => props.book.hasMetadataLocks)
const showSeriesPositionBadge = computed(
() => cardOverlays.value.includes('series-position') && props.book.seriesIndex != null && !props.selectionMode && !isMissing.value,
)
const seriesPositionLabel = computed(() => {
const index = props.book.seriesIndex
if (index == null) return ''
const display = index % 1 === 0 ? String(Math.trunc(index)) : String(index)
return `#${display}`
})
const seriesPositionTooltip = computed(() => {
const label = seriesPositionLabel.value
return props.book.seriesName ? `${props.book.seriesName} ${label}` : label
})
const ratingColor = computed(() => {
const r = Math.round(props.book.rating ?? 0)
if (r <= 1) return '#dc2626'
@@ -283,12 +298,27 @@ async function handleSetStatus(status: ReadStatus) {
<component :is="STATUS_ICONS[localReadStatus!]" :size="12" :class="STATUS_COLORS[localReadStatus!]" />
</div>
<!-- Top-right overlay: lock status -->
<div
v-if="showLockStatusPill"
class="absolute top-1.5 right-1.5 z-10 flex items-center justify-center rounded-full bg-black/60 p-1 pointer-events-none group-hover:opacity-0 transition-opacity duration-150"
>
<component :is="metadataLocked ? Lock : LockOpen" :size="12" :class="metadataLocked ? 'text-amber-400' : 'text-emerald-400'" />
<!-- Top-right overlay container: lock status (above) + series position (below) -->
<div class="absolute top-1.5 right-1.5 z-10 flex flex-col items-end gap-1 pointer-events-none">
<div
v-if="showLockStatusPill"
class="flex items-center justify-center rounded-full bg-black/60 p-1 group-hover:opacity-0 transition-opacity duration-150"
>
<component :is="metadataLocked ? Lock : LockOpen" :size="12" :class="metadataLocked ? 'text-amber-400' : 'text-emerald-400'" />
</div>
<Tooltip v-if="showSeriesPositionBadge">
<TooltipTrigger as-child>
<div
class="pointer-events-auto flex items-center bg-black/60 rounded-full px-1.5 py-0.5 group-hover:opacity-0 transition-opacity duration-150 cursor-default"
>
<span class="text-[9px] font-bold text-white leading-none">{{ seriesPositionLabel }}</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{{ seriesPositionTooltip }}</p>
</TooltipContent>
</Tooltip>
</div>
<!-- Bottom-left overlay: rating -->
@@ -40,6 +40,9 @@ const globalStubs = {
DropdownMenuSub: { template: '<div><slot /></div>' },
DropdownMenuSubTrigger: { template: '<div><slot /></div>' },
DropdownMenuSubContent: { template: '<div><slot /></div>' },
Tooltip: { template: '<div><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div data-testid="tooltip-content"><slot /></div>' },
},
}
@@ -243,3 +246,69 @@ describe('BookCoverCard — placeholder state', () => {
expect(imgs.length).toBeGreaterThan(0)
})
})
describe('BookCoverCard — series position overlay', () => {
const bookWithSeries: BookCard = {
...presentBook,
seriesName: 'The Expanse',
seriesIndex: 3,
}
afterEach(() => {
cardOverlays.value = ['progress-bar', 'format', 'rating', 'read-status']
})
it('renders the series badge when overlay is enabled and seriesIndex is set', () => {
cardOverlays.value = ['series-position']
const wrapper = mountCard(bookWithSeries)
expect(wrapper.text()).toContain('#3')
})
it('does not render the badge when series-position is not in overlays', () => {
cardOverlays.value = []
const wrapper = mountCard(bookWithSeries)
expect(wrapper.text()).not.toContain('#3')
})
it('does not render badge when seriesIndex is null', () => {
cardOverlays.value = ['series-position']
const wrapper = mountCard({ ...presentBook, seriesIndex: null, seriesName: 'Dune' })
expect(wrapper.text()).not.toContain('#')
})
it('formats whole-number float 3.0 as #3 (no trailing decimal)', () => {
cardOverlays.value = ['series-position']
const wrapper = mountCard({ ...presentBook, seriesIndex: 3.0, seriesName: 'Dune' })
expect(wrapper.text()).toContain('#3')
expect(wrapper.text()).not.toContain('#3.0')
})
it('formats fractional index 1.5 as #1.5', () => {
cardOverlays.value = ['series-position']
const wrapper = mountCard({ ...presentBook, seriesIndex: 1.5, seriesName: 'Dune' })
expect(wrapper.text()).toContain('#1.5')
})
it('tooltip content shows series name combined with number', () => {
cardOverlays.value = ['series-position']
const wrapper = mountCard(bookWithSeries)
const tooltip = wrapper.find('[data-testid="tooltip-content"]')
expect(tooltip.exists()).toBe(true)
expect(tooltip.text()).toContain('The Expanse #3')
})
it('tooltip shows only number when seriesName is null', () => {
cardOverlays.value = ['series-position']
const wrapper = mountCard({ ...presentBook, seriesIndex: 5, seriesName: null })
const tooltip = wrapper.find('[data-testid="tooltip-content"]')
expect(tooltip.exists()).toBe(true)
expect(tooltip.text()).toBe('#5')
})
it('lock and series badges coexist in the top-right container', () => {
cardOverlays.value = ['series-position', 'lock-status']
const wrapper = mountCard({ ...presentBook, seriesIndex: 2, seriesName: 'Dune', hasMetadataLocks: true })
expect(wrapper.text()).toContain('#2')
expect(wrapper.find('.text-amber-400').exists()).toBe(true)
})
})
@@ -34,6 +34,7 @@ const OVERLAY_OPTIONS: { key: CardOverlayKey; label: string; hint: string }[] =
{ key: 'format', label: 'File format', hint: 'Color-coded EPUB, PDF, CBZ badge at bottom-right' },
{ key: 'rating', label: 'Rating', hint: 'Star rating indicator at bottom-left' },
{ key: 'read-status', label: 'Read status', hint: 'Color icon showing the current reading status at top-left' },
{ key: 'series-position', label: 'Series number', hint: 'Badge showing the book position in its series at top-right (e.g. #3, #1.5)' },
{ key: 'lock-status', label: 'Lock status', hint: 'Metadata lock icon at top-right - orange when locked, green when unlocked' },
]
+1 -1
View File
@@ -2,7 +2,7 @@ module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [2, "always", ["feat", "fix", "db", "perf", "refactor", "style", "docs", "test", "build", "ci", "chore", "security", "revert"]],
"header-max-length": [2, "always", 72],
"header-max-length": [2, "always", 100],
"subject-full-stop": [2, "never", "."],
"scope-enum": [
2,