mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user