browse books

This commit is contained in:
Gauthier Roebroeck 2026-04-01 14:34:56 +08:00
parent 7441d42a1a
commit ad421020fb
11 changed files with 629 additions and 161 deletions

View file

@ -23,6 +23,7 @@ declare module 'vue' {
BookMenuBottomSheet: typeof import('./components/book/menu/BookMenuBottomSheet.vue')['default']
BuildCommit: typeof import('./components/BuildCommit.vue')['default']
BuildVersion: typeof import('./components/BuildVersion.vue')['default']
ChipCount: typeof import('./components/ChipCount.vue')['default']
DialogBookPicker: typeof import('./components/dialog/BookPicker.vue')['default']
DialogConfirm: typeof import('./components/dialog/Confirm.vue')['default']
DialogConfirmEdit: typeof import('./components/dialog/ConfirmEdit.vue')['default']
@ -35,7 +36,7 @@ declare module 'vue' {
EmptyStateConstruction: typeof import('./components/EmptyStateConstruction.vue')['default']
EmptyStateNetworkError: typeof import('./components/EmptyStateNetworkError.vue')['default']
FilterAnyAll: typeof import('./components/filter/AnyAll.vue')['default']
FilterButton: typeof import('./components/FilterButton.vue')['default']
FilterButton: typeof import('./components/filter/FilterButton.vue')['default']
FilterByAgeRating: typeof import('./components/filter/by/AgeRating.vue')['default']
FilterByAuthor: typeof import('./components/filter/by/Author.vue')['default']
FilterByComplete: typeof import('./components/filter/by/Complete.vue')['default']
@ -50,6 +51,7 @@ declare module 'vue' {
FilterBySharingLabel: typeof import('./components/filter/by/SharingLabel.vue')['default']
FilterByTag: typeof import('./components/filter/by/Tag.vue')['default']
FilterByUnavailable: typeof import('./components/filter/by/Unavailable.vue')['default']
FilterChipCount: typeof import('./components/filter/ChipCount.vue')['default']
FilterExpansionPanel: typeof import('./components/filter/ExpansionPanel.vue')['default']
FilterIncludeExclude: typeof import('./components/filter/IncludeExclude.vue')['default']
FilterList: typeof import('./components/filter/List.vue')['default']
@ -68,6 +70,7 @@ declare module 'vue' {
ImportBooksDirectorySelection: typeof import('./components/import/books/DirectorySelection.vue')['default']
ImportBooksTransientBooksTable: typeof import('./components/import/books/TransientBooksTable.vue')['default']
ImportReadlistTable: typeof import('./components/import/readlist/Table.vue')['default']
ItemBrowser: typeof import('./components/item/Browser.vue')['default']
ItemCard: typeof import('./components/item/card/ItemCard.vue')['default']
ItemCardWide: typeof import('./components/item/CardWide/ItemCardWide.vue')['default']
LayoutAppBar: typeof import('./components/layout/app/Bar.vue')['default']

View file

@ -0,0 +1,18 @@
<template>
<v-chip
v-if="count"
rounded
class="ms-4"
>{{ count }}</v-chip
>
</template>
<script setup lang="ts">
const {} = defineProps<{
count?: number
}>()
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -0,0 +1,27 @@
<template>
<v-chip
v-if="count > 0"
color="primary"
rounded
closable
variant="elevated"
size="small"
@click:close="emit('clear')"
>
{{ count }}
</v-chip>
</template>
<script setup lang="ts">
const {} = defineProps<{
count: number
}>()
const emit = defineEmits<{
clear: []
}>()
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -0,0 +1,66 @@
<template>
<v-data-iterator
v-model="selectionStore.selection"
return-object
:items="items"
:items-per-page="appStore.browsingPaging === 'paged' ? itemsPerPage : -1"
:page="appStore.browsingPaging === 'paged' ? page1 : 1"
show-select
>
<template #default="{ items: internalItems, toggleSelect, isSelected }">
<v-container fluid>
<v-row>
<v-col
v-for="(item, idx) in internalItems"
:key="idx"
:cols="presentationMode === 'grid' ? 'auto' : 12"
>
<slot
:item="item.raw"
:is-selected="isSelected(item)"
:pre-select="selectionStore.isNotEmpty"
:toggle-select="(event: MouseEvent) => toggleSelect(item, idx, event)"
/>
</v-col>
</v-row>
</v-container>
</template>
</v-data-iterator>
<v-pagination
v-if="appStore.isBrowsingPaged"
v-model="page1"
:length="pageCount"
/>
<div
v-if="appStore.isBrowsingScroll && hasNextPage"
v-intersect="(isIntersecting: boolean) => (isIntersecting ? emit('loadNextPage') : undefined)"
style="min-height: 40px"
></div>
</template>
<script setup lang="ts" generic="T">
import { useAppStore } from '@/stores/app'
import { useSelectionStore } from '@/stores/selection'
import type { PresentationMode } from '@/types/libraries'
import { useItemsPerPage } from '@/composables/pagination'
const appStore = useAppStore()
const page1 = defineModel<number>('page1', { required: true })
const { items, presentationMode, hasNextPage, pageCount } = defineProps<{
items?: T[]
presentationMode: PresentationMode
hasNextPage: boolean
pageCount: number
}>()
const emit = defineEmits<{
loadNextPage: []
}>()
const selectionStore = useSelectionStore()
const { itemsPerPage } = useItemsPerPage(appStore.browsingPageSize)
</script>

View file

@ -0,0 +1,37 @@
import * as v from 'valibot'
import { SchemaFilterAuthors } from '@/types/filter'
import { useRouteQuerySchema } from '@/composables/useRouteQuerySchema'
import { authorRoles } from '@/types/referential'
import { useIntl } from 'vue-intl'
export function useFilterAuthors() {
const intl = useIntl()
const filterAuthors = reactive<
Record<
string,
{ filter: v.InferOutput<typeof SchemaFilterAuthors>; text: string; role?: string }
>
>({})
filterAuthors['anyrole'] = {
filter: useRouteQuerySchema('anyrole', SchemaFilterAuthors).data.value,
text: intl.formatMessage({
description: 'Author filter: any role',
defaultMessage: 'All creators',
id: 'RmNasP',
}),
}
// TODO: get roles dynamically
Object.entries(authorRoles).forEach(([role, value]) => {
filterAuthors[role] = {
filter: useRouteQuerySchema(role, SchemaFilterAuthors).data.value,
text: intl.formatMessage(value),
role: role,
}
})
return {
filterAuthors: filterAuthors,
}
}

View file

@ -1,5 +1,7 @@
import {
type FilterIncludeExclude,
type FilterType,
type FilterTypeSelectRange,
SchemaAnyNone,
SchemaFilterAuthors,
SchemaFilterReadStatus,
@ -11,6 +13,15 @@ import {
import type { InferOutput } from 'valibot'
import * as v from 'valibot'
export function clearFilter(filter: FilterType | FilterTypeSelectRange | FilterIncludeExclude) {
if ('v' in filter) filter.v = []
if ('m' in filter) filter.m = 'anyOf'
if ('is' in filter) filter.is = undefined
if ('min' in filter) filter.min = undefined
if ('max' in filter) filter.max = undefined
if ('i' in filter) filter.i = undefined
}
export function schemaFilterSeriesStatusToConditions(
filter: InferOutput<typeof SchemaFilterSeriesStatus>,
) {

View file

@ -1,10 +1,291 @@
series.vue
<template>
BOOKS
<EmptyStateConstruction />
<v-app-bar>
<ChipCount :count="totalElements" />
<v-spacer />
<PosterSizeSlider />
<PresentationSelector
v-if="display.smAndUp.value"
v-model="presentationMode"
:modes="['grid', 'list']"
toggle
/>
<PageSizeSelector
v-if="appStore.isBrowsingPaged"
v-model="appStore.browsingPageSize"
allow-unpaged
:sizes="[1, 10, 20]"
/>
<PagingSelector
v-model="appStore.browsingPaging"
class="px-2"
/>
<FilterButton
:count="filterCount"
@click="filterDrawer = true"
/>
</v-app-bar>
<TempDrawer v-model="filterDrawer">
<v-list>
<v-list-subheader>
<div class="d-flex ga-2 align-center mb-1">
<span>{{ $formatMessage(commonMessages.filterPanelHeader) }}</span>
<FilterChipCount
:count="filterCount"
@clear="clearFilters()"
/>
</div>
</v-list-subheader>
<FilterByReadStatus
v-model="filterReadStatus.v"
class="py-0"
/>
<v-list class="py-0">
<FilterByOneShot v-model="filterOneShot" />
<FilterByUnavailable v-model="filterUnavailable" />
</v-list>
<v-expansion-panels
v-model="filterExpansionPanels"
variant="accordion"
flat
tile
>
<FilterExpansionPanel
:title="$formatMessage(commonMessages.filterPanelTag)"
:count="filterTag.v.length"
@clear="clearFilter(filterTag)"
>
<FilterByTag
v-model="filterTag.v"
v-model:mode="filterTag.m"
/>
</FilterExpansionPanel>
</v-expansion-panels>
<v-divider
><span class="text-body-medium text-medium-emphasis">{{
$formatMessage(commonMessages.filterPanelCreators)
}}</span></v-divider
>
<v-expansion-panels
v-model="filterExpansionPanels"
variant="accordion"
class="no-padding"
flat
tile
>
<FilterExpansionPanel
v-for="(filterAuthor, role) in filterAuthors"
:key="role"
:title="filterAuthor.text"
:count="filterAuthor.filter.v.length"
@clear="clearFilter(filterAuthor.filter)"
>
<FilterByAuthor
v-model="filterAuthor.filter.v"
v-model:mode="filterAuthor.filter.m"
:role="filterAuthor.role"
/>
</FilterExpansionPanel>
</v-expansion-panels>
<v-divider />
<v-list-subheader>{{ $formatMessage(commonMessages.filterPanelSort) }}</v-list-subheader>
<SortList
v-model="sortActive"
:items="sortOptions"
mandatory
multi-sort
/>
</v-list>
</TempDrawer>
<ItemBrowser
v-model:page1="page1"
:items="dataItems"
:presentation-mode="presentationModeEffective"
:has-next-page="hasNextPage"
:page-count="pageCount"
@load-next-page="loadNextPage()"
>
<template #default="{ item, isSelected, preSelect, toggleSelect }">
<BookCard
stretch-poster
:book="item"
:selected="isSelected"
:pre-select="preSelect"
:width="display.xs.value ? undefined : appStore.gridCardWidth"
@selection="(_val, event) => toggleSelect(event as MouseEvent)"
/>
</template>
</ItemBrowser>
</template>
<script lang="ts" setup></script>
<script lang="ts" setup>
import PosterSizeSlider from '@/components/PosterSizeSlider.vue'
import FilterButton from '@/components/filter/FilterButton.vue'
import { useDisplay } from 'vuetify'
import { usePresentationMode } from '@/composables/presentationMode'
import { useGetLibrariesById } from '@/composables/libraries'
import { useSearchConditionLibraries } from '@/composables/search'
import { useAppStore } from '@/stores/app'
import { storeToRefs } from 'pinia'
import { usePagination } from '@/composables/pagination'
import { useSelectionStore } from '@/stores/selection'
import { SchemaFilterReadStatus, SchemaFilterStrings, SchemaIncludeExclude } from '@/types/filter'
import { useRouteQuerySchema } from '@/composables/useRouteQuerySchema'
import { useIntlFormatter } from '@/composables/intlFormatter'
import { sortBooks } from '@/types/sort'
import type { components } from '@/generated/openapi/komga'
import {
clearFilter,
schemaFilterAuthorsToConditions,
schemaFilterIncludeExcludeToConditions,
schemaFilterReadStatusToConditions,
schemaFilterStringToConditions,
} from '@/functions/filter'
import { useInfiniteQuery, useQuery } from '@pinia/colada'
import { PageRequest, sortToString } from '@/types/PageRequest'
import { komgaClient } from '@/api/komga-client'
import { bookListQuery } from '@/colada/books'
import { commonMessages } from '@/utils/i18n/common-messages'
import { useFilterAuthors } from '@/composables/filters'
import ChipCount from '@/components/ChipCount.vue'
const route = useRoute('/libraries/[id]/books')
const libraryId = route.params.id
const { libraries } = useGetLibrariesById(libraryId)
const { librariesCondition } = useSearchConditionLibraries(libraries)
const display = useDisplay()
const appStore = useAppStore()
const { isBrowsingScroll, isBrowsingPaged } = storeToRefs(appStore)
const viewName = computed(() => `${libraryId}_books`)
const { presentationMode, presentationModeEffective } = usePresentationMode(viewName)
const { page0, page1, pageCount } = usePagination()
const selectionStore = useSelectionStore()
function clearFilters() {
clearFilter(filterReadStatus.value)
clearFilter(filterTag.value)
clearFilter(filterUnavailable.value)
clearFilter(filterOneShot.value)
Object.entries(filterAuthors).map(([, filter]) => clearFilter(filter.filter))
}
const filterCount = computed(
() =>
(!!filterUnavailable.value.i ? 1 : 0) +
(!!filterOneShot.value.i ? 1 : 0) +
filterReadStatus.value.v.length +
filterTag.value.v.length +
Object.entries(filterAuthors)
.map(([, filter]) => filter.filter.v.length)
.reduce((sum, item) => sum + item, 0),
)
const { filterAuthors } = useFilterAuthors()
const { data: filterReadStatus } = useRouteQuerySchema('read', SchemaFilterReadStatus)
const { data: filterTag } = useRouteQuerySchema('tag', SchemaFilterStrings)
const { data: filterUnavailable } = useRouteQuerySchema('unavailable', SchemaIncludeExclude)
const { data: filterOneShot } = useRouteQuerySchema('oneshot', SchemaIncludeExclude)
const { convertSortOptionDescriptor } = useIntlFormatter()
const sortActive = appStore.getSortActive(viewName.value, [
{ key: 'series', order: 'asc' },
{ key: 'metadata.numberSort', order: 'asc' },
])
const sortOptions = sortBooks.map((it) => convertSortOptionDescriptor(it))
const conds = computed(() => ({
allOf: [
librariesCondition.value,
schemaFilterIncludeExcludeToConditions(filterUnavailable.value, 'deleted'),
schemaFilterIncludeExcludeToConditions(filterOneShot.value, 'oneShot'),
schemaFilterReadStatusToConditions(filterReadStatus.value),
schemaFilterStringToConditions(filterTag.value, 'tag', true),
...Object.entries(filterAuthors).map(([, filter]) =>
schemaFilterAuthorsToConditions(toValue(filter.filter), toValue(filter.role)),
),
].filter(Boolean),
}))
// clear selection if filter or paging changes
watch([conds, () => appStore.browsingPaging], () => selectionStore.clear())
const apiQuery = computed(() => ({
condition: conds.value as components['schemas']['AllOfBook'],
}))
const { data: dataPaged } = useQuery(() => ({
...bookListQuery({
search: { ...apiQuery.value },
pageRequest: PageRequest.FromPageSize(appStore.browsingPageSize, page0.value, sortActive.value),
}),
enabled: isBrowsingPaged.value,
}))
watch(dataPaged, (newDataPaged) => {
if (newDataPaged) pageCount.value = newDataPaged.totalPages ?? 0
})
const {
data: dataInfinite,
loadNextPage,
hasNextPage,
} = useInfiniteQuery({
key: () => ['infinite_books', apiQuery.value, sortActive.value],
initialPageParam: new PageRequest(0, 50, sortActive.value),
query: ({ pageParam }) =>
komgaClient
.POST('/api/v1/books/list', {
body: apiQuery.value,
params: {
query: {
page: pageParam.page,
size: pageParam.size,
sort: sortActive.value.map((it) => sortToString(it)),
},
},
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
getNextPageParam: (lastPage, _, lastPageParam) => (!lastPage?.last ? lastPageParam.next() : null),
enabled: isBrowsingScroll,
})
const dataInfiniteFlat = computed(() =>
dataInfinite.value?.pages.flatMap((it) => it?.content ?? []),
)
const dataItems = computed(() =>
isBrowsingPaged.value ? dataPaged.value?.content : dataInfiniteFlat.value,
)
const totalElements = computed(() =>
isBrowsingPaged.value
? dataPaged.value?.totalElements
: dataInfinite.value?.pages?.[0]?.totalElements,
)
const filterDrawer = ref(false)
// shared model for all the expansion-panels, so only 1 is opened at the same time
const filterExpansionPanels = ref()
</script>
<route lang="yaml">
meta:

View file

@ -1,5 +1,7 @@
<template>
<v-app-bar>
<ChipCount :count="totalElements" />
<v-spacer />
<PosterSizeSlider />
@ -12,7 +14,7 @@
/>
<PageSizeSelector
v-if="appStore.isBrowsingPaged"
v-if="isBrowsingPaged"
v-model="appStore.browsingPageSize"
allow-unpaged
:sizes="[1, 10, 20]"
@ -34,17 +36,10 @@
<v-list-subheader>
<div class="d-flex ga-2 align-center mb-1">
<span>{{ $formatMessage(commonMessages.filterPanelHeader) }}</span>
<v-chip
v-if="filterCount > 0"
color="primary"
rounded
closable
variant="elevated"
size="small"
@click:close="clearFilters()"
>
{{ filterCount }}
</v-chip>
<FilterChipCount
:count="filterCount"
@clear="clearFilters()"
/>
</div>
</v-list-subheader>
@ -62,7 +57,6 @@
<v-expansion-panels
v-model="filterExpansionPanels"
variant="accordion"
class="no-padding"
flat
tile
>
@ -110,7 +104,7 @@
<FilterExpansionPanel
:title="$formatMessage(commonMessages.filterPanelReleaseYear)"
:count="!!filterReleaseYear.is ? 1 : !!filterReleaseYear.min ? 1 : 0"
@clear="clearFilterSelectRange(filterReleaseYear)"
@clear="clearFilter(filterReleaseYear)"
>
<FilterByReleaseYear v-model="filterReleaseYear" />
</FilterExpansionPanel>
@ -118,7 +112,7 @@
<FilterExpansionPanel
:title="$formatMessage(commonMessages.filterPanelAgeRating)"
:count="!!filterAgeRating.is ? 1 : !!filterAgeRating.min ? 1 : 0"
@clear="clearFilterSelectRange(filterAgeRating)"
@clear="clearFilter(filterAgeRating)"
>
<FilterByAgeRating v-model="filterAgeRating" />
</FilterExpansionPanel>
@ -186,58 +180,36 @@
</v-list>
</TempDrawer>
<v-data-iterator
v-model="selectedItems"
return-object
:items="seriesItems"
:items-per-page="appStore.browsingPaging === 'paged' ? itemsPerPage : -1"
:page="appStore.browsingPaging === 'paged' ? page1 : 1"
show-select
<ItemBrowser
v-model:page1="page1"
:items="dataItems"
:presentation-mode="presentationModeEffective"
:has-next-page="hasNextPage"
:page-count="pageCount"
@load-next-page="loadNextPage()"
>
<template #default="{ items, toggleSelect, isSelected }">
<v-container fluid>
<v-row>
<v-col
v-for="(item, idx) in items"
:key="item.raw.id"
:cols="presentationModeEffective === 'grid' ? 'auto' : 12"
>
<SeriesCard
v-if="presentationModeEffective === 'grid'"
stretch-poster
:series="item.raw"
:selected="isSelected(item)"
:pre-select="preSelect"
:width="display.xs.value ? undefined : appStore.gridCardWidth"
@selection="(_val, event) => toggleSelect(item, idx, event as MouseEvent)"
/>
<template #default="{ item, isSelected, preSelect, toggleSelect }">
<SeriesCard
v-if="presentationModeEffective === 'grid'"
stretch-poster
:series="item"
:selected="isSelected"
:pre-select="preSelect"
:width="display.xs.value ? undefined : appStore.gridCardWidth"
@selection="(_val, event) => toggleSelect(event as MouseEvent)"
/>
<SeriesCardWide
v-if="presentationModeEffective === 'list'"
stretch-poster
:series="item.raw"
:selected="isSelected(item)"
:pre-select="preSelect"
:width="appStore.gridCardWidth"
@selection="(_val, event) => toggleSelect(item, idx, event as MouseEvent)"
/>
</v-col>
</v-row>
</v-container>
<SeriesCardWide
v-if="presentationModeEffective === 'list'"
stretch-poster
:series="item"
:selected="isSelected"
:pre-select="preSelect"
:width="appStore.gridCardWidth"
@selection="(_val, event) => toggleSelect(event as MouseEvent)"
/>
</template>
</v-data-iterator>
<v-pagination
v-if="appStore.isBrowsingPaged"
v-model="page1"
:length="pageCount"
/>
<div
v-if="appStore.isBrowsingScroll && hasNextPage"
v-intersect="(isIntersecting: boolean) => (isIntersecting ? loadMore() : undefined)"
style="min-height: 40px"
></div>
</ItemBrowser>
</template>
<script lang="ts" setup>
@ -247,9 +219,8 @@ import type { components } from '@/generated/openapi/komga'
import { PageRequest, sortToString } from '@/types/PageRequest'
import { useGetLibrariesById } from '@/composables/libraries'
import { useAppStore } from '@/stores/app'
import { useItemsPerPage, usePagination } from '@/composables/pagination'
import { usePagination } from '@/composables/pagination'
import { useSearchConditionLibraries } from '@/composables/search'
import { storeToRefs } from 'pinia'
import { useSelectionStore } from '@/stores/selection'
import { useDisplay } from 'vuetify'
import {
@ -260,13 +231,9 @@ import {
schemaFilterAgeRatingToConditions,
schemaFilterReadStatusToConditions,
schemaFilterIncludeExcludeToConditions,
clearFilter,
} from '@/functions/filter'
import * as v from 'valibot'
import {
type FilterIncludeExclude,
type FilterType,
type FilterTypeSelectRange,
SchemaFilterAuthors,
SchemaFilterReadStatus,
SchemaFilterSeriesStatus,
SchemaFilterStrings,
@ -275,72 +242,32 @@ import {
SchemaSeriesReleaseYears,
} from '@/types/filter'
import { useRouteQuerySchema } from '@/composables/useRouteQuerySchema'
import { authorRoles } from '@/types/referential'
import { useIntl } from 'vue-intl'
import { commonMessages } from '@/utils/i18n/common-messages'
import { useIntlFormatter } from '@/composables/intlFormatter'
import { sortSeries } from '@/types/sort'
import { komgaClient } from '@/api/komga-client'
import PosterSizeSlider from '@/components/PosterSizeSlider.vue'
import FilterButton from '@/components/FilterButton.vue'
import FilterButton from '@/components/filter/FilterButton.vue'
import { usePresentationMode } from '@/composables/presentationMode'
import { storeToRefs } from 'pinia'
import { useFilterAuthors } from '@/composables/filters'
import ChipCount from '@/components/ChipCount.vue'
const route = useRoute('/libraries/[id]/series')
const libraryId = route.params.id
const { libraries } = useGetLibrariesById(libraryId)
const { librariesCondition } = useSearchConditionLibraries(libraries)
const intl = useIntl()
const display = useDisplay()
const appStore = useAppStore()
const { isBrowsingScroll, isBrowsingPaged } = storeToRefs(appStore)
const { browsingPageSize } = storeToRefs(appStore)
const viewName = computed(() => `${libraryId}_series`)
const { presentationMode, presentationModeEffective } = usePresentationMode(viewName)
const { itemsPerPage } = useItemsPerPage(browsingPageSize)
const { page0, page1, pageCount } = usePagination()
const selectionStore = useSelectionStore()
const { selection: selectedItems } = storeToRefs(selectionStore)
const preSelect = computed(() => selectedItems.value.length > 0)
type AuthorQuery = v.InferOutput<typeof SchemaFilterAuthors>
const filterAuthors = reactive<
Record<string, { filter: AuthorQuery; text: string; role?: string }>
>({})
filterAuthors['anyrole'] = {
filter: useRouteQuerySchema('anyrole', SchemaFilterAuthors).data.value,
text: intl.formatMessage({
description: 'Author filter: any role',
defaultMessage: 'All creators',
id: 'RmNasP',
}),
}
// TODO: get roles dynamically
Object.entries(authorRoles).forEach(([role, value]) => {
filterAuthors[role] = {
filter: useRouteQuerySchema(role, SchemaFilterAuthors).data.value,
text: intl.formatMessage(value),
role: role,
}
})
function clearFilter(filter: FilterType) {
filter.v = []
if ('m' in filter) filter.m = 'anyOf'
}
function clearFilterSelectRange(filter: FilterTypeSelectRange) {
filter.is = undefined
filter.min = undefined
filter.max = undefined
}
function clearFilterSolo(filter: FilterIncludeExclude) {
filter.i = undefined
}
function clearFilters() {
clearFilter(filterSeriesStatus.value)
@ -350,11 +277,11 @@ function clearFilters() {
clearFilter(filterPublisher.value)
clearFilter(filterSharingLabel.value)
clearFilter(filterLanguage.value)
clearFilterSolo(filterComplete.value)
clearFilterSolo(filterUnavailable.value)
clearFilterSolo(filterOneShot.value)
clearFilterSelectRange(filterReleaseYear.value)
clearFilterSelectRange(filterAgeRating.value)
clearFilter(filterComplete.value)
clearFilter(filterUnavailable.value)
clearFilter(filterOneShot.value)
clearFilter(filterReleaseYear.value)
clearFilter(filterAgeRating.value)
Object.entries(filterAuthors).map(([, filter]) => clearFilter(filter.filter))
}
@ -377,6 +304,7 @@ const filterCount = computed(
.reduce((sum, item) => sum + item, 0),
)
const { filterAuthors } = useFilterAuthors()
const { data: filterSeriesStatus } = useRouteQuerySchema('status', SchemaFilterSeriesStatus)
const { data: filterReadStatus } = useRouteQuerySchema('read', SchemaFilterReadStatus)
const { data: filterGenre } = useRouteQuerySchema('genre', SchemaFilterStrings)
@ -398,7 +326,7 @@ const sortOptions = sortSeries.map((it) => convertSortOptionDescriptor(it))
const conds = computed(() => ({
allOf: [
librariesCondition.value as components['schemas']['AnyOfSeries'],
librariesCondition.value,
schemaFilterIncludeExcludeToConditions(filterComplete.value, 'complete'),
schemaFilterIncludeExcludeToConditions(filterUnavailable.value, 'deleted'),
schemaFilterIncludeExcludeToConditions(filterOneShot.value, 'oneShot'),
@ -424,20 +352,20 @@ const apiQuery = computed(() => ({
condition: conds.value as components['schemas']['AllOfSeries'],
}))
const { data: series } = useQuery(() => ({
const { data: dataPaged } = useQuery(() => ({
...seriesListQuery({
search: { ...apiQuery.value },
pageRequest: PageRequest.FromPageSize(appStore.browsingPageSize, page0.value, sortActive.value),
}),
enabled: appStore.isBrowsingPaged,
enabled: isBrowsingPaged.value,
}))
watch(series, (newSeries) => {
if (newSeries) pageCount.value = newSeries.totalPages ?? 0
watch(dataPaged, (newDataPaged) => {
if (newDataPaged) pageCount.value = newDataPaged.totalPages ?? 0
})
const {
data: infiniteData,
data: dataInfinite,
loadNextPage,
hasNextPage,
} = useInfiniteQuery({
@ -458,17 +386,20 @@ const {
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
getNextPageParam: (lastPage, _, lastPageParam) => (!lastPage?.last ? lastPageParam.next() : null),
enabled: appStore.isBrowsingScroll,
enabled: isBrowsingScroll,
})
const infiniteSeries = computed(() => infiniteData.value?.pages.flatMap((it) => it?.content ?? []))
const seriesItems = computed(() =>
appStore.isBrowsingPaged ? series.value?.content : infiniteSeries.value,
const dataInfiniteFlat = computed(() =>
dataInfinite.value?.pages.flatMap((it) => it?.content ?? []),
)
function loadMore() {
void loadNextPage()
}
const dataItems = computed(() =>
isBrowsingPaged.value ? dataPaged.value?.content : dataInfiniteFlat.value,
)
const totalElements = computed(() =>
isBrowsingPaged.value
? dataPaged.value?.totalElements
: dataInfinite.value?.pages?.[0]?.totalElements,
)
const filterDrawer = ref(false)
@ -476,11 +407,7 @@ const filterDrawer = ref(false)
const filterExpansionPanels = ref()
</script>
<style lang="scss">
.no-padding .v-expansion-panel-text__wrapper {
padding: 0 4px;
}
</style>
<style lang="scss"></style>
<route lang="yaml">
meta:

View file

@ -13,9 +13,10 @@ export const useSelectionStore = defineStore('selection', () => {
)
const isEmpty = computed(() => selection.value.length === 0)
const isNotEmpty = computed(() => selection.value.length > 0)
const count = computed(() => selection.value.length)
const clear = () => (selection.value = [])
return { selection, count, isEmpty, clear }
return { selection, count, isEmpty, isNotEmpty, clear }
})

View file

@ -15,6 +15,24 @@ export type SortOrder = 'asc' | 'desc'
export type SortOptionDescriptor = Omit<SortOption, 'label'> & { message: MessageDescriptor }
const messages = {
createdDate: defineMessage({
description: 'Sort label: createdDate',
defaultMessage: 'Date added',
id: 'TG7prC',
}),
lastModifiedDate: defineMessage({
description: 'Sort label: lastModifiedDate',
defaultMessage: 'Date updated',
id: 'VHe28r',
}),
readDate: defineMessage({
description: 'Sort label: readDate',
defaultMessage: 'Date read',
id: 'NasBHg',
}),
}
export const sortSeries: SortOptionDescriptor[] = [
{
message: defineMessage({
@ -27,31 +45,19 @@ export const sortSeries: SortOptionDescriptor[] = [
invertible: true,
},
{
message: defineMessage({
description: 'Sort label: createdDate',
defaultMessage: 'Date added',
id: 'TG7prC',
}),
message: messages.createdDate,
key: 'createdDate',
initialOrder: 'desc',
invertible: true,
},
{
message: defineMessage({
description: 'Sort label: lastModifiedDate',
defaultMessage: 'Date updated',
id: 'VHe28r',
}),
message: messages.lastModifiedDate,
key: 'lastModifiedDate',
initialOrder: 'desc',
invertible: true,
},
{
message: defineMessage({
description: 'Sort label: readDate',
defaultMessage: 'Date read',
id: 'NasBHg',
}),
message: messages.readDate,
key: 'readDate',
initialOrder: 'desc',
invertible: true,
@ -97,3 +103,94 @@ export const sortSeries: SortOptionDescriptor[] = [
invertible: false,
},
]
export const sortBooks: SortOptionDescriptor[] = [
{
message: defineMessage({
description: 'Sort label: series',
defaultMessage: 'Series',
id: 'X47Js+',
}),
key: 'series',
initialOrder: 'asc',
invertible: true,
},
{
message: defineMessage({
description: 'Sort label: metadata.numberSort',
defaultMessage: 'Number',
id: 'r/G7j0',
}),
key: 'metadata.numberSort',
initialOrder: 'asc',
invertible: true,
},
{
message: defineMessage({
description: 'Sort label: metadata.title',
defaultMessage: 'Name',
id: 'nXWSTf',
}),
key: 'metadata.title',
initialOrder: 'asc',
invertible: true,
},
{
message: messages.createdDate,
key: 'createdDate',
initialOrder: 'desc',
invertible: true,
},
{
message: messages.lastModifiedDate,
key: 'lastModifiedDate',
initialOrder: 'desc',
invertible: true,
},
{
message: defineMessage({
description: 'Sort label: metadata.releaseDate',
defaultMessage: 'Release date',
id: 'Uj479p',
}),
key: 'metadata.releaseDate',
initialOrder: 'desc',
invertible: true,
},
{
message: messages.readDate,
key: 'readDate',
initialOrder: 'desc',
invertible: true,
},
{
message: defineMessage({
description: 'Sort label: fileSize',
defaultMessage: 'File size',
id: 'Y/fJj5',
}),
key: 'fileSize',
initialOrder: 'desc',
invertible: true,
},
{
message: defineMessage({
description: 'Sort label: name',
defaultMessage: 'File name',
id: '+jaADC',
}),
key: 'name',
initialOrder: 'asc',
invertible: true,
},
{
message: defineMessage({
description: 'Sort label: pagesCount',
defaultMessage: 'Page count',
id: 'WVblsI',
}),
key: 'pagesCount',
initialOrder: 'asc',
invertible: false,
},
]