mirror of
https://github.com/gotson/komga.git
synced 2026-05-08 12:35:30 +02:00
browse books
This commit is contained in:
parent
7441d42a1a
commit
ad421020fb
11 changed files with 629 additions and 161 deletions
5
next-ui/src/components.d.ts
vendored
5
next-ui/src/components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
18
next-ui/src/components/ChipCount.vue
Normal file
18
next-ui/src/components/ChipCount.vue
Normal 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>
|
||||
27
next-ui/src/components/filter/ChipCount.vue
Normal file
27
next-ui/src/components/filter/ChipCount.vue
Normal 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>
|
||||
66
next-ui/src/components/item/Browser.vue
Normal file
66
next-ui/src/components/item/Browser.vue
Normal 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>
|
||||
37
next-ui/src/composables/filters.ts
Normal file
37
next-ui/src/composables/filters.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue