mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(books): filter read-along titles
Expose read-along EPUBs as an additive format filter while preserving ordinary EPUB matching and existing client compatibility. Closes #1583
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BOOK_FORMATS, READ_ALONG_FORMAT_PRIORITY } from '@bookorbit/types'
|
||||
import FilterFormatPicker from './FilterFormatPicker.vue'
|
||||
|
||||
describe('FilterFormatPicker', () => {
|
||||
it('offers read-along beside the physical book formats', async () => {
|
||||
const wrapper = mount(FilterFormatPicker, { props: { modelValue: [] } })
|
||||
const buttons = wrapper.findAll('button')
|
||||
const readAlong = buttons.find((button) => button.attributes('aria-label') === 'Read-along EPUB')
|
||||
|
||||
expect(buttons).toHaveLength(BOOK_FORMATS.length + 1)
|
||||
expect(readAlong).toBeDefined()
|
||||
await readAlong!.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[[READ_ALONG_FORMAT_PRIORITY]]])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { BOOK_FORMATS } from '@bookorbit/types'
|
||||
import { Headphones } from '@lucide/vue'
|
||||
import { BOOK_FORMATS, isReadAlongFormatKey, withReadAlongFormatPriority } from '@bookorbit/types'
|
||||
import { formatKeyCode, formatKeyName } from '@/features/book/lib/book-formats'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
@@ -7,6 +9,8 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
|
||||
|
||||
const formatKeys = withReadAlongFormatPriority(BOOK_FORMATS)
|
||||
|
||||
function toggle(format: string) {
|
||||
if (props.modelValue.includes(format)) {
|
||||
emit(
|
||||
@@ -22,9 +26,11 @@ function toggle(format: string) {
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
v-for="format in BOOK_FORMATS"
|
||||
v-for="format in formatKeys"
|
||||
:key="format"
|
||||
type="button"
|
||||
:aria-label="formatKeyName(format)"
|
||||
:title="formatKeyName(format)"
|
||||
class="h-7 px-2.5 rounded-md border text-xs font-medium uppercase tracking-wide transition-colors"
|
||||
:class="
|
||||
modelValue.includes(format)
|
||||
@@ -33,7 +39,8 @@ function toggle(format: string) {
|
||||
"
|
||||
@click="toggle(format)"
|
||||
>
|
||||
{{ format }}
|
||||
{{ formatKeyCode(format) }}
|
||||
<Headphones v-if="isReadAlongFormatKey(format)" class="ms-1 inline size-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { GroupRule, Rule } from '@bookorbit/types'
|
||||
import { READ_ALONG_FORMAT_PRIORITY } from '@bookorbit/types'
|
||||
import { useFilterTokens } from '../useFilterTokens'
|
||||
import { ruleToTokenParts } from '../../lib/filter-labels'
|
||||
|
||||
@@ -95,6 +96,11 @@ describe('ruleToTokenParts', () => {
|
||||
value: 'Want to Read',
|
||||
})
|
||||
expect(ruleToTokenParts(epub)).toEqual({ field: 'Format', operator: 'is', value: 'EPUB' })
|
||||
expect(ruleToTokenParts({ ...epub, value: [READ_ALONG_FORMAT_PRIORITY] })).toEqual({
|
||||
field: 'Format',
|
||||
operator: 'is',
|
||||
value: 'Read-along EPUB',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the full operator when several values are picked', () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Rule, RuleField, RuleOperator, SortField } from '@bookorbit/types'
|
||||
import { parseCustomRuleFieldId, parseCustomSortFieldId } from '@bookorbit/types'
|
||||
import { isReadAlongFormatKey, parseCustomRuleFieldId, parseCustomSortFieldId } from '@bookorbit/types'
|
||||
import { i18n } from '@/i18n'
|
||||
import { activeCustomFieldLabel } from '@/features/book/composables/useActiveCustomFields'
|
||||
import { PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import { formatKeyName } from './book-formats'
|
||||
|
||||
// Resolved through the global composer rather than useI18n() because these helpers also run
|
||||
// outside a component setup (useViewSort, plain computeds). Composer.t tracks the active locale,
|
||||
@@ -74,10 +75,14 @@ function tokenValueLabel(field: Rule['field'], value: string): string {
|
||||
const label = t(key)
|
||||
return label === key ? value : label
|
||||
}
|
||||
if (field === 'format') return value.toUpperCase()
|
||||
if (field === 'format') return formatFilterValueLabel(value)
|
||||
return value
|
||||
}
|
||||
|
||||
export function formatFilterValueLabel(value: string): string {
|
||||
return isReadAlongFormatKey(value) ? formatKeyName(value) : value.toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact form a rule takes as a token in the browse search field. A single-value
|
||||
* "includes any of" reads as "is", read statuses use their translated names and formats
|
||||
|
||||
@@ -51,7 +51,7 @@ import { useLibraryScan } from '@/features/scanner/composables/useLibraryScan'
|
||||
import { useFilterTokens } from '@/features/book/composables/useFilterTokens'
|
||||
import { useLibraryFacets } from '@/features/library/composables/useLibraryFacets'
|
||||
import { useLibraryHeaderFacts } from '@/features/library/composables/useLibraryHeaderFacts'
|
||||
import { sortFieldLabel } from '@/features/book/lib/filter-labels'
|
||||
import { formatFilterValueLabel, sortFieldLabel } from '@/features/book/lib/filter-labels'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import { formatRelativeTimeFromNow } from '@/i18n/formatters'
|
||||
import { useEffectiveViewMode } from '@/composables/useEffectiveViewMode'
|
||||
@@ -343,7 +343,7 @@ const { tokens: filterTokens, selectedFormats, ruleCount: filterRuleCount, remov
|
||||
const { facets } = useLibraryFacets(libraryId, filter, debouncedQuery)
|
||||
const formatFacets = computed(() => facets.value?.formats ?? [])
|
||||
const showFormatFacets = computed(() => formatFacets.value.length > 1 || selectedFormats.value.length > 0)
|
||||
const selectedFormatsLabel = computed(() => (selectedFormats.value.length > 0 ? selectedFormats.value.map((f) => f.toUpperCase()).join(', ') : null))
|
||||
const selectedFormatsLabel = computed(() => (selectedFormats.value.length > 0 ? selectedFormats.value.map(formatFilterValueLabel).join(', ') : null))
|
||||
const isNarrowed = computed(() => filterRuleCount.value > 0 || debouncedQuery.value.trim().length > 0)
|
||||
|
||||
const canManageLibraries = computed(() => hasPermission('manage_libraries'))
|
||||
|
||||
Reference in New Issue
Block a user